├── .coveragerc ├── .gitattributes ├── .github ├── dependabot.yml ├── utils │ └── check_version.py └── workflows │ ├── pypi.yml │ └── tests.yml ├── .gitignore ├── .readthedocs.yml ├── CHANGES.rst ├── LICENSE ├── README.rst ├── all-requirements.txt ├── artwork ├── flask-multipass-long.svg ├── flask-multipass.png └── flask-multipass.svg ├── docs ├── .gitignore ├── Makefile ├── _static │ ├── flask-multipass-long.png │ └── flask-multipass.png ├── _templates │ ├── sidebarintro.html │ └── sidebarlogo.html ├── api.rst ├── changelog.rst ├── conf.py ├── config_example.rst ├── index.rst └── quickstart.rst ├── example ├── example.cfg.example ├── example.py ├── requirements.txt └── templates │ ├── base.html │ ├── group.html │ ├── index.html │ ├── login_form.html │ └── login_selector.html ├── flask_multipass ├── __init__.py ├── auth.py ├── core.py ├── data.py ├── exceptions.py ├── group.py ├── identity.py ├── providers │ ├── __init__.py │ ├── authlib.py │ ├── ldap │ │ ├── __init__.py │ │ ├── exceptions.py │ │ ├── globals.py │ │ ├── operations.py │ │ ├── providers.py │ │ └── util.py │ ├── saml.py │ ├── shibboleth.py │ ├── sqlalchemy.py │ └── static.py └── util.py ├── pyproject.toml ├── pytest.ini ├── ruff.toml ├── tests ├── __init__.py ├── conftest.py ├── providers │ └── ldap │ │ ├── test_operations.py │ │ ├── test_providers.py │ │ └── test_util.py ├── test_auth.py ├── test_core.py ├── test_data.py ├── test_identity.py └── test_util.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | source = flask_multipass 4 | 5 | [report] 6 | exclude_lines = 7 | pragma: no cover 8 | def __repr__ 9 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | CHANGES.rst merge=union 2 | docs/* linguist-documentation 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | day: monday 8 | time: '14:30' 9 | timezone: Europe/Zurich 10 | groups: 11 | github-actions: 12 | patterns: ['*'] 13 | -------------------------------------------------------------------------------- /.github/utils/check_version.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | import tomllib 5 | 6 | data = tomllib.loads(Path('pyproject.toml').read_text()) 7 | version = data['project']['version'] 8 | tag_version = sys.argv[1] 9 | 10 | if tag_version != version: 11 | print(f'::error::Tag version {tag_version} does not match package version {version}') 12 | sys.exit(1) 13 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: PyPI release 🐍 📦 2 | 3 | on: 4 | push: 5 | tags: [v*] 6 | 7 | jobs: 8 | build: 9 | name: Build package 📦 10 | runs-on: ubuntu-22.04 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-python@v5 14 | name: Set up Python 🐍 15 | with: 16 | python-version: '3.11' 17 | - name: Check version 🔍 18 | run: python .github/utils/check_version.py "${GITHUB_REF#refs/tags/v}" 19 | - name: Install build deps 🔧 20 | run: pip install --user build 21 | - name: Build wheel and sdist 📦 22 | run: >- 23 | python -m 24 | build 25 | --outdir dist/ 26 | . 27 | - uses: actions/upload-artifact@v4 28 | name: Upload build artifacts 📦 29 | with: 30 | name: wheel 31 | retention-days: 7 32 | path: ./dist 33 | 34 | create-github-release: 35 | name: Create GitHub release 🐙 36 | # Upload wheel to a GitHub release. It remains available as a build artifact for a while as well. 37 | needs: build 38 | runs-on: ubuntu-22.04 39 | permissions: 40 | contents: write 41 | steps: 42 | - uses: actions/download-artifact@v4 43 | name: Download build artifacts 📦 44 | - name: Create draft release 🐙 45 | run: >- 46 | gh release create 47 | --draft 48 | --repo ${{ github.repository }} 49 | --title ${{ github.ref_name }} 50 | ${{ github.ref_name }} 51 | wheel/* 52 | env: 53 | GH_TOKEN: ${{ github.token }} 54 | 55 | publish-pypi: 56 | name: Publish 🚀 57 | needs: build 58 | # Wait for approval before attempting to upload to PyPI. This allows reviewing the 59 | # files in the draft release. 60 | environment: publish 61 | runs-on: ubuntu-22.04 62 | permissions: 63 | contents: write 64 | id-token: write 65 | steps: 66 | - uses: actions/download-artifact@v4 67 | # Try uploading to Test PyPI first, in case something fails. 68 | - name: Publish to Test PyPI 🧪 69 | uses: pypa/gh-action-pypi-publish@v1.12.4 70 | with: 71 | repository-url: https://test.pypi.org/legacy/ 72 | packages-dir: wheel/ 73 | skip-existing: true 74 | attestations: false 75 | - name: Publish to PyPI 🚀 76 | uses: pypa/gh-action-pypi-publish@v1.12.4 77 | with: 78 | packages-dir: wheel/ 79 | - name: Publish GitHub release 🐙 80 | run: >- 81 | gh release edit 82 | --draft=false 83 | --repo ${{ github.repository }} 84 | ${{ github.ref_name }} 85 | env: 86 | GH_TOKEN: ${{ github.token }} 87 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - '*.x' 8 | pull_request: 9 | branches: 10 | - master 11 | - '*.x' 12 | 13 | jobs: 14 | tests: 15 | name: ${{ matrix.name }} 16 | runs-on: ubuntu-22.04 17 | 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | include: 22 | - {name: Style, python: '3.13', tox: style} 23 | - {name: '3.13', python: '3.13', tox: py313} 24 | - {name: '3.12', python: '3.12', tox: py312} 25 | - {name: '3.11', python: '3.11', tox: py311} 26 | - {name: '3.10', python: '3.10', tox: py310} 27 | - {name: '3.9', python: '3.9', tox: py39} 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | 32 | - uses: actions/setup-python@v5 33 | with: 34 | python-version: ${{ matrix.python }} 35 | 36 | - name: Install ldap deps 37 | run: sudo apt-get install libsasl2-dev libldap2-dev libssl-dev 38 | 39 | - name: Install uv 40 | run: curl -LsSf https://astral.sh/uv/install.sh | sh 41 | 42 | - name: Install tox 43 | run: uv pip install --system tox 44 | 45 | - name: Run tests 46 | run: tox -e ${{ matrix.tox }} 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache/ 2 | .eggs/ 3 | .idea/ 4 | .vscode/ 5 | .tox/ 6 | build/ 7 | dist/ 8 | .venv/ 9 | htmlcov/ 10 | 11 | *.egg 12 | *.egg-info 13 | *.pyc 14 | *.swp 15 | .coverage 16 | 17 | example/example.cfg 18 | .python-version 19 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: '3.11' 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | builder: dirhtml 11 | 12 | python: 13 | install: 14 | - requirements: all-requirements.txt 15 | - method: pip 16 | path: . 17 | extra_requirements: 18 | - dev 19 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | Version 0.10 5 | ------------ 6 | 7 | - Allow overriding the message of ``NoSuchUser`` and ``InvalidCredentials``, and 8 | make its other arguments keyword-only 9 | 10 | Version 0.9 11 | ----------- 12 | 13 | - Include the username in the ``identifier`` attribute of the ``NoSuchUser`` 14 | exception so applications can apply e.g. per-username rate limiting 15 | - Fail silently when there's no ``objectSid`` for an AD-style LDAP group 16 | 17 | Version 0.8 18 | ----------- 19 | 20 | - Reject ``next`` URLs containing linebreaks gracefully 21 | - Look for ``logout_uri`` in top-level authlib provider config instead of the 22 | ``authlib_args`` dict (the latter is still checked as a fallback) 23 | - Include ``id_token_hint`` in authlib logout URL 24 | - Add ``logout_args`` setting to authlib provider which allows removing some of 25 | the query string arguments that are included by default 26 | 27 | Version 0.7 28 | ----------- 29 | 30 | - Support multiple id fields in SAML identity provider 31 | - Include ``client_id`` in authlib logout URL since some OIDC providers may require this 32 | - Allow setting timeout for authlib token requests (default: 10 seconds) 33 | - Add new ``MULTIPASS_HIDE_NO_SUCH_USER`` config setting to convert ``NoSuchUser`` 34 | exceptions to ``InvalidCredentials`` to avoid disclosing whether a username is valid 35 | - Include the username in the ``identifier`` attribute of the ``InvalidCredentials`` 36 | exception so applications can apply e.g. per-username rate limiting 37 | 38 | Version 0.6 39 | ----------- 40 | 41 | - Drop support for Python 3.8 (3.8 is EOL since Oct 2024) 42 | - Remove upper version pins of dependencies 43 | - Support friendly names for SAML assertions (set ``'saml_friendly_names': True`` 44 | in the auth provider settings) 45 | - Include more verbose authentication data in ``IdentityRetrievalFailed`` exception details 46 | 47 | Version 0.5.6 48 | ------------- 49 | 50 | - Reject invalid ``next`` URLs with backslashes that could be used to trick browsers into 51 | redirecting to an otherwise disallowed host when doing client-side redirects 52 | 53 | Version 0.5.5 54 | ------------- 55 | 56 | - Ensure only valid schemas (http and https) can be used when validating the ``next`` URL 57 | - Deprecate the ``flask_multipass.__version__`` attribute 58 | 59 | Version 0.5.4 60 | ------------- 61 | 62 | - Skip LDAP users that do not have the specified ``uid`` attribute set instead 63 | of failing with an error 64 | 65 | Version 0.5.3 66 | ------------- 67 | 68 | - Skip LDAP group members that do not have the specified ``uid`` attribute set instead 69 | of failing with an error 70 | 71 | Version 0.5.2 72 | ------------- 73 | 74 | - Add ``ldap_or_authinfo`` identity provider which behaves exactly like the ``ldap`` 75 | provider, but if the user cannot be found in LDAP, it falls back to the data 76 | from the auth provider (typically shibboleth) 77 | 78 | Version 0.5.1 79 | ------------- 80 | 81 | - Fix compatibility with Python 3.8 and 3.9 82 | 83 | Version 0.5 84 | ----------- 85 | 86 | - Drop support for Python 3.7 and older (3.7 is EOL since June 2023) 87 | - Declare explicit compatibility with Python 3.11 88 | - Support werkzeug 3.0 89 | - Fail more gracefully if Authlib (OIDC) login provider is down 90 | 91 | Version 0.4.9 92 | ------------- 93 | 94 | - Support authlib 1.1 (remove upper version pin) 95 | 96 | Version 0.4.8 97 | ------------- 98 | 99 | - Fix LDAP TLS configuration 100 | 101 | Version 0.4.7 102 | ------------- 103 | 104 | - Declare explicit compatibility with Python 3.10 105 | 106 | Version 0.4.6 107 | ------------- 108 | 109 | - Support authlib 1.0.0rc1 (up to 1.0.x) 110 | 111 | Version 0.4.5 112 | ------------- 113 | 114 | - Log details when getting oauth token fails 115 | 116 | Version 0.4.4 117 | ------------- 118 | 119 | - Support authlib 1.0.0b2 120 | 121 | Version 0.4.3 122 | ------------- 123 | 124 | - Add ``saml`` provider which supports SAML without the need for Shibboleth and Apache 125 | 126 | Version 0.4.2 127 | ------------- 128 | 129 | - Fix LDAP group membership checks on servers that are not using ``ad_group_style`` 130 | 131 | Version 0.4.1 132 | ------------- 133 | 134 | - Support authlib 1.0.0a2 135 | 136 | Version 0.4 137 | ----------- 138 | 139 | - Drop support for Python 2; Python 3.6+ is now required 140 | 141 | Version 0.3.5 142 | ------------- 143 | 144 | - Validate ``next`` URL to avoid having an open redirector 145 | 146 | Version 0.3.4 147 | ------------- 148 | 149 | - Fix authlib dependency to work with 1.0.0a1 (which no longer has a ``client`` extra) 150 | 151 | Version 0.3.3 152 | ------------- 153 | 154 | - Add missing dependencies for ``ldap`` and ``sqlalchemy`` extras 155 | - Add support for authlib 1.0.0a1 156 | - Add explicit support for Python 3.9 157 | 158 | Version 0.3.2 159 | ------------- 160 | 161 | - Require a recent ``python-ldap`` version when enabling the ``ldap`` extra. 162 | 163 | Version 0.3.1 164 | ------------- 165 | 166 | - Add ``search_identities_ex`` which allows more a flexible search with the option 167 | to specify the max number of results to return while also returning the total number 168 | of found identities. 169 | 170 | Version 0.3 171 | ----------- 172 | 173 | - **Breaking change:** Replace ``oauth`` provider with ``authlib``. 174 | - **Breaking change:** Drop support for Python 3.4 and 3.5. 175 | - The new authlib provider supports OIDC (OpenID-Connect) in addition to regular OAuth. 176 | - Make ``ldap`` provider compatible with Python 3. 177 | 178 | Version 0.2 179 | ----------- 180 | 181 | - Add option to get all groups for an identity. 182 | 183 | Version 0.1 184 | ----------- 185 | 186 | - Initial release 187 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Flask-Multipass is free software; you can redistribute it and/or 2 | modify it under the terms of the Revised BSD License quoted below. 3 | 4 | Copyright (C) 2015 - 2017 CERN. 5 | 6 | All rights reserved. 7 | 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions are 10 | met: 11 | 12 | * Redistributions of source code must retain the above copyright 13 | notice, this list of conditions and the following disclaimer. 14 | 15 | * Redistributions in binary form must reproduce the above copyright 16 | notice, this list of conditions and the following disclaimer in the 17 | documentation and/or other materials provided with the distribution. 18 | 19 | * Neither the name of the copyright holder nor the names of its 20 | contributors may be used to endorse or promote products derived from 21 | this software without specific prior written permission. 22 | 23 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 24 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 25 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 26 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 27 | HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 28 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 29 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS 30 | OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 31 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR 32 | TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE 33 | USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH 34 | DAMAGE. 35 | 36 | In applying this license, CERN does not waive the privileges and 37 | immunities granted to it by virtue of its status as an 38 | Intergovernmental Organization or submit itself to any jurisdiction. 39 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Flask-Multipass 2 | =============== 3 | 4 | .. image:: https://raw.githubusercontent.com/indico/flask-multipass/master/artwork/flask-multipass.png 5 | 6 | .. image:: https://readthedocs.org/projects/flask-multipass/badge/?version=latest 7 | :target: https://flask-multipass.readthedocs.org/ 8 | .. image:: https://github.com/indico/flask-multipass/workflows/Tests/badge.svg 9 | :target: https://github.com/indico/flask-multipass/actions 10 | 11 | Flask-Multipass provides Flask with a user authentication/identity 12 | system which can use different backends (such as local users, 13 | LDAP and OAuth) simultaneously. 14 | 15 | It was developed at CERN and is currently used in production by `Indico `_. 16 | 17 | There are bult-in authentication and identity providers for: 18 | 19 | * `Static (hardcoded) credentials `_ 20 | * `Local (SQLAlchemy DB) authentication `_ 21 | * `Authlib (OAuth/OIDC) `_ 22 | * `SAML `_ 23 | * `Shibboleth `_ 24 | * `LDAP `_ 25 | 26 | Those can be used simultaneously and interchangeably (e.g. authenticate with OAuth and search users with LDAP). 27 | 28 | Documentation is available at https://flask-multipass.readthedocs.org 29 | -------------------------------------------------------------------------------- /all-requirements.txt: -------------------------------------------------------------------------------- 1 | # All dependencies; including those only needed for some providers. 2 | # This file is mostly there so readthedocs can build everything. 3 | # Strong dependencies are already listed in setup.py 4 | 5 | flask 6 | flask_wtf 7 | flask_sqlalchemy 8 | wtforms 9 | sphinx>=7.2.6 10 | flask_sphinx_themes 11 | authlib 12 | -------------------------------------------------------------------------------- /artwork/flask-multipass.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indico/flask-multipass/e44bab00f8c1fab2a57063670ebcf60ee723d9b5/artwork/flask-multipass.png -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | _build/ 2 | -------------------------------------------------------------------------------- /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/Flask-Multipass.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Flask-Multipass.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/Flask-Multipass" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Flask-Multipass" 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/flask-multipass-long.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indico/flask-multipass/e44bab00f8c1fab2a57063670ebcf60ee723d9b5/docs/_static/flask-multipass-long.png -------------------------------------------------------------------------------- /docs/_static/flask-multipass.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indico/flask-multipass/e44bab00f8c1fab2a57063670ebcf60ee723d9b5/docs/_static/flask-multipass.png -------------------------------------------------------------------------------- /docs/_templates/sidebarintro.html: -------------------------------------------------------------------------------- 1 |

About

2 |

3 | Flask-Multipass is an extension that provides a user authentication 4 | system for Flask which can use multiple backends (such as local users, 5 | LDAP and OAuth) simultaneously. 6 |

7 |

Useful Links

8 | 12 | -------------------------------------------------------------------------------- /docs/_templates/sidebarlogo.html: -------------------------------------------------------------------------------- 1 | 6 |

{{ project }}

7 |

A user authentication system for Flask.

8 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | === 3 | 4 | 5 | Core 6 | ---- 7 | .. automodule:: flask_multipass.core 8 | :members: 9 | 10 | .. _auth_providers: 11 | 12 | Authentication Providers 13 | ------------------------ 14 | .. automodule:: flask_multipass.auth 15 | :members: 16 | .. autoclass:: flask_multipass.providers.ldap.LDAPAuthProvider 17 | :members: 18 | .. autoclass:: flask_multipass.providers.authlib.AuthlibAuthProvider 19 | :members: 20 | .. autoclass:: flask_multipass.providers.saml.SAMLAuthProvider 21 | :members: 22 | .. autoclass:: flask_multipass.providers.static.StaticAuthProvider 23 | :members: 24 | .. autoclass:: flask_multipass.providers.shibboleth.ShibbolethAuthProvider 25 | :members: 26 | .. autoclass:: flask_multipass.providers.sqlalchemy.SQLAlchemyAuthProviderBase 27 | :members: 28 | 29 | .. _identity_providers: 30 | 31 | Identity Providers 32 | ----------------------- 33 | .. automodule:: flask_multipass.identity 34 | :members: 35 | .. autoclass:: flask_multipass.providers.ldap.LDAPIdentityProvider 36 | :members: 37 | .. autoclass:: flask_multipass.providers.authlib.AuthlibIdentityProvider 38 | :members: 39 | .. autoclass:: flask_multipass.providers.saml.SAMLIdentityProvider 40 | :members: 41 | .. autoclass:: flask_multipass.providers.static.StaticIdentityProvider 42 | :members: 43 | .. autoclass:: flask_multipass.providers.shibboleth.ShibbolethIdentityProvider 44 | :members: 45 | .. autoclass:: flask_multipass.providers.sqlalchemy.SQLAlchemyIdentityProviderBase 46 | :members: 47 | 48 | 49 | Data Structures 50 | --------------- 51 | .. automodule:: flask_multipass.data 52 | :members: 53 | 54 | 55 | Groups 56 | ------ 57 | .. automodule:: flask_multipass.group 58 | :members: 59 | 60 | 61 | Utils 62 | ----- 63 | .. automodule:: flask_multipass.util 64 | :members: 65 | :exclude-members: expand_provider_links, get_state, classproperty, validate_provider_map 66 | 67 | 68 | Exceptions 69 | ---------- 70 | .. automodule:: flask_multipass.exceptions 71 | :members: 72 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGES.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # 2 | # Flask-Multipass documentation build configuration file, created by 3 | # sphinx-quickstart on Mon Mar 30 11:56:17 2015. 4 | # 5 | # This file is execfile()d with the current directory set to its 6 | # containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import importlib.metadata 15 | import os 16 | import sys 17 | from unittest.mock import MagicMock 18 | 19 | # If extensions (or modules to document with autodoc) are in another directory, 20 | # add these directories to sys.path here. If the directory is relative to the 21 | # documentation root, use os.path.abspath to make it absolute, like shown here. 22 | sys.path.insert(0, os.path.abspath('_themes')) 23 | 24 | # Mock away ldap/saml 25 | for module in ('ldap', 'ldap.controls', 'ldap.filter', 'ldap.ldapobject', 'urlparse', 'onelogin.saml2.auth'): 26 | sys.modules[module] = MagicMock() 27 | 28 | # -- General configuration ------------------------------------------------ 29 | 30 | # If your documentation needs a minimal Sphinx version, state it here. 31 | #needs_sphinx = '1.0' 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 35 | # ones. 36 | extensions = [ 37 | 'sphinx.ext.autodoc', 38 | 'sphinx.ext.intersphinx', 39 | ] 40 | 41 | # Add any paths that contain templates here, relative to this directory. 42 | templates_path = ['_templates'] 43 | 44 | # The suffix(es) of source filenames. 45 | # You can specify multiple suffix as a list of string: 46 | # source_suffix = ['.rst', '.md'] 47 | source_suffix = '.rst' 48 | 49 | # The encoding of source files. 50 | #source_encoding = 'utf-8-sig' 51 | 52 | # The master toctree document. 53 | master_doc = 'index' 54 | 55 | # General information about the project. 56 | project = 'Flask-Multipass' 57 | copyright = '2015, CERN' 58 | author = 'Indico Team' 59 | 60 | # The version info for the project you're documenting, acts as replacement for 61 | # |version| and |release|, also used in various other places throughout the 62 | # built documents. 63 | try: 64 | release = importlib.metadata.version('flask-multipass') 65 | except importlib.metadata.PackageNotFoundError: 66 | print('To build the documentation, The distribution information') 67 | print('of Flask-Multipass has to be available.') 68 | sys.exit(1) 69 | del importlib 70 | 71 | if 'dev' in release: 72 | release = release.split('dev')[0] + 'dev' 73 | version = '.'.join(release.split('.')[:2]) 74 | 75 | primary_domain = 'py' 76 | 77 | # The language for content autogenerated by Sphinx. Refer to documentation 78 | # for a list of supported languages. 79 | # 80 | # This is also used if you do content translation via gettext catalogs. 81 | # Usually you set "language" from the command line for these cases. 82 | language = None 83 | 84 | # There are two options for replacing |today|: either, you set today to some 85 | # non-false value, then it is used: 86 | #today = '' 87 | # Else, today_fmt is used as the format for a strftime call. 88 | #today_fmt = '%B %d, %Y' 89 | 90 | # List of patterns, relative to source directory, that match files and 91 | # directories to ignore when looking for source files. 92 | exclude_patterns = ['_build'] 93 | 94 | # The reST default role (used for this markup: `text`) to use for all 95 | # documents. 96 | #default_role = None 97 | 98 | # If true, '()' will be appended to :func: etc. cross-reference text. 99 | #add_function_parentheses = True 100 | 101 | # If true, the current module name will be prepended to all description 102 | # unit titles (such as .. function::). 103 | #add_module_names = True 104 | 105 | # If true, sectionauthor and moduleauthor directives will be shown in the 106 | # output. They are ignored by default. 107 | #show_authors = False 108 | 109 | # A list of ignored prefixes for module index sorting. 110 | #modindex_common_prefix = [] 111 | 112 | # If true, keep warnings as "system message" paragraphs in the built documents. 113 | #keep_warnings = False 114 | 115 | # If true, `todo` and `todoList` produce output, else they produce nothing. 116 | todo_include_todos = False 117 | 118 | 119 | # -- Options for HTML output ---------------------------------------------- 120 | html_theme_path = ['_themes'] 121 | html_theme = 'flask' 122 | html_theme_options = { 123 | 'index_logo_height': '120px', 124 | 'index_logo': 'flask-multipass-long.png', 125 | } 126 | 127 | # Add any paths that contain custom themes here, relative to this directory. 128 | 129 | # The name for this set of Sphinx documents. If None, it defaults to 130 | # " v documentation". 131 | #html_title = None 132 | 133 | # A shorter title for the navigation bar. Default is the same as html_title. 134 | #html_short_title = None 135 | 136 | # The name of an image file (relative to this directory) to place at the top 137 | # of the sidebar. 138 | #html_logo = None 139 | 140 | # The name of an image file (within the static path) to use as favicon of the 141 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 142 | # pixels large. 143 | #html_favicon = None 144 | 145 | # Add any paths that contain custom static files (such as style sheets) here, 146 | # relative to this directory. They are copied after the builtin static files, 147 | # so a file named "default.css" will overwrite the builtin "default.css". 148 | html_static_path = ['_static'] 149 | 150 | # Add any extra paths that contain custom files (such as robots.txt or 151 | # .htaccess) here, relative to this directory. These files are copied 152 | # directly to the root of the documentation. 153 | #html_extra_path = [] 154 | 155 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 156 | # using the given strftime format. 157 | #html_last_updated_fmt = '%b %d, %Y' 158 | 159 | # If true, SmartyPants will be used to convert quotes and dashes to 160 | # typographically correct entities. 161 | #html_use_smartypants = True 162 | 163 | # Custom sidebar templates, maps document names to template names. 164 | html_sidebars = { 165 | 'index': ['sidebarintro.html', 'sourcelink.html', 'searchbox.html'], 166 | '**': ['sidebarlogo.html', 'localtoc.html', 'relations.html', 'sourcelink.html', 'searchbox.html'], 167 | } 168 | 169 | # Additional templates that should be rendered to pages, maps page names to 170 | # template names. 171 | #html_additional_pages = {} 172 | 173 | # If false, no module index is generated. 174 | #html_domain_indices = True 175 | 176 | # If false, no index is generated. 177 | #html_use_index = True 178 | 179 | # If true, the index is split into individual pages for each letter. 180 | #html_split_index = False 181 | 182 | # If true, links to the reST sources are added to the pages. 183 | #html_show_sourcelink = True 184 | 185 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 186 | #html_show_sphinx = True 187 | 188 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 189 | #html_show_copyright = True 190 | 191 | # If true, an OpenSearch description file will be output, and all pages will 192 | # contain a tag referring to it. The value of this option must be the 193 | # base URL from which the finished HTML is served. 194 | #html_use_opensearch = '' 195 | 196 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 197 | #html_file_suffix = None 198 | 199 | # Language to be used for generating the HTML full-text search index. 200 | # Sphinx supports the following languages: 201 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' 202 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr' 203 | #html_search_language = 'en' 204 | 205 | # A dictionary with options for the search language support, empty by default. 206 | # Now only 'ja' uses this config value 207 | #html_search_options = {'type': 'default'} 208 | 209 | # The name of a javascript file (relative to the configuration directory) that 210 | # implements a search results scorer. If empty, the default will be used. 211 | #html_search_scorer = 'scorer.js' 212 | 213 | # Output file base name for HTML help builder. 214 | htmlhelp_basename = 'Flask-Multipassdoc' 215 | 216 | # -- Options for LaTeX output --------------------------------------------- 217 | 218 | latex_elements = { 219 | # The paper size ('letterpaper' or 'a4paper'). 220 | #'papersize': 'letterpaper', 221 | 222 | # The font size ('10pt', '11pt' or '12pt'). 223 | #'pointsize': '10pt', 224 | 225 | # Additional stuff for the LaTeX preamble. 226 | #'preamble': '', 227 | 228 | # Latex figure (float) alignment 229 | #'figure_align': 'htbp', 230 | } 231 | 232 | # Grouping the document tree into LaTeX files. List of tuples 233 | # (source start file, target name, title, 234 | # author, documentclass [howto, manual, or own class]). 235 | latex_documents = [(master_doc, 'Flask-Multipass.tex', 'Flask-Multipass Documentation', 'Indico Team', 'manual')] 236 | 237 | # The name of an image file (relative to this directory) to place at the top of 238 | # the title page. 239 | #latex_logo = None 240 | 241 | # For "manual" documents, if this is true, then toplevel headings are parts, 242 | # not chapters. 243 | #latex_use_parts = False 244 | 245 | # If true, show page references after internal links. 246 | #latex_show_pagerefs = False 247 | 248 | # If true, show URL addresses after external links. 249 | #latex_show_urls = False 250 | 251 | # Documents to append as an appendix to all manuals. 252 | #latex_appendices = [] 253 | 254 | # If false, no module index is generated. 255 | #latex_domain_indices = True 256 | 257 | 258 | # -- Options for manual page output --------------------------------------- 259 | 260 | # One entry per manual page. List of tuples 261 | # (source start file, name, description, authors, manual section). 262 | man_pages = [ 263 | (master_doc, 'flask-multipass', 'Flask-Multipass Documentation', 264 | [author], 1), 265 | ] 266 | 267 | # If true, show URL addresses after external links. 268 | #man_show_urls = False 269 | 270 | 271 | # -- Options for Texinfo output ------------------------------------------- 272 | 273 | # Grouping the document tree into Texinfo files. List of tuples 274 | # (source start file, target name, title, author, 275 | # dir menu entry, description, category) 276 | texinfo_documents = [(master_doc, 'Flask-Multipass', 'Flask-Multipass Documentation', author, 'Flask-Multipass', 277 | 'One line description of project.', 'Miscellaneous')] 278 | -------------------------------------------------------------------------------- /docs/config_example.rst: -------------------------------------------------------------------------------- 1 | 2 | .. _config_example: 3 | 4 | Configuration example 5 | ===================== 6 | 7 | This configuration can be added to your Flask configuration file that you use when initializing flask application. However, you can configure Multipass also directly through the application object, for example: 8 | 9 | .. code-block:: python 10 | 11 | app.config['MULTIPASS_LOGIN_URLS'] = {'/my_login/', '/my_login/'} 12 | 13 | Here you can see an example configuration for an application using both external and local providers. 14 | 15 | ``'test_auth_provider'`` is a dummy example of a local authentication provider, it's linked to the ``'test_identity_provider'`` as specified in ``MULTIPASS_PROVIDER_MAP``. You can read more about the configuration of local providers here: :ref:`local_providers` 16 | 17 | ``'github'``, ``'shib'``, ``'saml'`` and ``'my-ldap'`` are examples of external providers. More on configuration of external providers: :ref:`external_providers` 18 | 19 | .. code-block:: python 20 | 21 | _github_oauth_config = { 22 | 'client_id': '', # put your client id here 23 | 'client_secret': '', # put your client secret here 24 | 'authorize_url': 'https://github.com/login/oauth/authorize', 25 | 'access_token_url': 'https://github.com/login/oauth/access_token', 26 | 'api_base_url': 'https://api.github.com', 27 | 'userinfo_endpoint': '/user', 28 | } 29 | 30 | 31 | _my_ldap_config = { 32 | 'uri': 'ldaps://ldap.example.com:636', 33 | 'bind_dn': 'uid=admin,DC=example,DC=com', 34 | 'bind_password': 'p455w0rd', 35 | 'timeout': 30, 36 | 'verify_cert': True, 37 | # optional: if not present, uses certifi's CA bundle (if installed) 38 | 'cert_file': 'path/to/server/cert', 39 | 'starttls': False, 40 | 'page_size': 1000, 41 | 42 | 'uid': 'uid', 43 | 'user_base': 'OU=Users,DC=example,DC=com', 44 | 'user_filter': '(objectCategory=person)', 45 | 46 | 'gid': 'cn', 47 | 'group_base': 'OU=Organizational Units,DC=example,DC=com', 48 | 'group_filter': '(objectCategory=groupOfNames)', 49 | 'member_of_attr': 'memberOf', 50 | 'ad_group_style': False, 51 | } 52 | 53 | _my_saml_config = { 54 | # If enabled, errors are more verbose and received attributes are 55 | # printed to stdout 56 | # 'debug': True, 57 | 'sp': { 58 | # this needs to match what you use when registering the SAML SP 59 | 'entityId': 'multipass-saml-test', 60 | # these bindings usually do not need to be set manually; the URLs 61 | # are auto-generated 62 | # 'assertionConsumerService': {'binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'}, 63 | # 'singleLogoutService': {'binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'}, 64 | # depending on your security config you may need to generate a 65 | # certificate and private key. 66 | # you can use https://www.samltool.com/self_signed_certs.php or 67 | # use `openssl` for it (which is more secure as it ensures the 68 | # key never leaves your machine) 69 | 'x509cert': '', 70 | 'privateKey': '', 71 | # persistent name ids are the default, but you can override it 72 | # with a custom format 73 | # 'NameIDFormat': 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' 74 | }, 75 | 'idp': { 76 | # This metadata is provided by your SAML IdP. You can omit (or 77 | # leave empty) the whole 'idp' section in case you need SP 78 | # metadata to register your app and get the IdP metadata from 79 | # https://yourapp.example.com/multipass/saml/{auth-provider-name}/metadata 80 | # and then fill in the IdP metadata afterwards. 81 | 'entityId': 'https://idp.example.com', 82 | 'x509cert': '', 83 | 'singleSignOnService': { 84 | 'url': 'https://idp.example.com/saml', 85 | 'binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' 86 | }, 87 | # If you do not want to perform a SAML logout when logging out 88 | # from your application, you can omit this section 89 | 'singleLogoutService': { 90 | 'url': 'https://idp.example.com/saml', 91 | 'binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' 92 | } 93 | }, 94 | # These advanced settings allow you to tune the SAML security options. 95 | # Please see the documentation on https://github.com/onelogin/python3-saml 96 | # for details on how they behave. Note that by requiring signatures, 97 | # you usually need to set a cert and key on your SP config, but for 98 | # testing you may want to disable all signing-related options. 99 | 'security': { 100 | 'nameIdEncrypted': False, 101 | 'authnRequestsSigned': True, 102 | 'logoutRequestSigned': True, 103 | 'logoutResponseSigned': True, 104 | 'signMetadata': True, 105 | 'wantMessagesSigned': True, 106 | 'wantAssertionsSigned': True, 107 | 'wantNameId' : True, 108 | 'wantNameIdEncrypted': False, 109 | 'wantAssertionsEncrypted': False, 110 | 'allowSingleLabelDomains': False, 111 | 'signatureAlgorithm': 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256', 112 | 'digestAlgorithm': 'http://www.w3.org/2001/04/xmlenc#sha256' 113 | }, 114 | # You can set contact information which will be included in the SP 115 | # metadata; it is up to the IdP if/how this information is used: 116 | # 'contactPerson': { 117 | # 'technical': { 118 | # 'givenName': 'technical_name', 119 | # 'emailAddress': 'technical@example.com' 120 | # }, 121 | # 'support': { 122 | # 'givenName': 'support_name', 123 | # 'emailAddress': 'support@example.com' 124 | # } 125 | # }, 126 | # 'organization': { 127 | # 'en-US': { 128 | # 'name': 'sp_test', 129 | # 'displayname': 'SP test', 130 | # 'url': 'http://sp.example.com' 131 | # } 132 | # } 133 | } 134 | 135 | MULTIPASS_AUTH_PROVIDERS = { 136 | 'test_auth_provider': { 137 | 'type': 'static', 138 | 'title': 'Insecure dummy auth', 139 | 'identities': { 140 | 'Pig': 'pig123', 141 | 'Bunny': 'bunny123' 142 | } 143 | }, 144 | 'github': { 145 | 'type': 'authlib', 146 | 'title': 'GitHub', 147 | 'authlib_args': _github_oauth_config 148 | }, 149 | 'my-ldap': { 150 | 'type': 'ldap', 151 | 'title': 'My Organization LDAP', 152 | 'ldap': _my_ldap_config, 153 | }, 154 | 'shib': { 155 | 'type': 'shibboleth', 156 | 'title': 'Shibboleth SSO', 157 | 'callback_uri': '/shibboleth/sso', 158 | 'logout_uri': 'https://sso.example.com/logout' 159 | }, 160 | 'saml': { 161 | 'type': 'saml', 162 | 'title': 'SAML SSO', 163 | 'saml_config': _my_saml_config, 164 | # If your IdP is ADFS you may need to enable this. For details, see 165 | # https://github.com/onelogin/python-saml/pull/144 166 | # 'lowercase_urlencoding': True 167 | }, 168 | } 169 | 170 | MULTIPASS_IDENTITY_PROVIDERS = { 171 | 'test_identity_provider': { 172 | 'type': 'static', 173 | 'identities': { 174 | 'Pig': {'email': 'guinea.pig@example.com', 'name': 'Guinea Pig', 'affiliation': 'Pig University'}, 175 | 'Bunny': {'email': 'bugs.bunny@example.com', 'name': 'Bugs Bunny', 'affiliation': 'Bunny Inc.'} 176 | }, 177 | 'groups': { 178 | 'Admins': ['Pig'], 179 | 'Everybody': ['Pig', 'Bunny'], 180 | } 181 | }, 182 | 'github': { 183 | 'type': 'authlib', 184 | 'title': 'GitHub', 185 | 'identifier_field': 'id', 186 | 'mapping': { 187 | 'affiliation': 'company', 188 | 'first_name': 'name' 189 | } 190 | }, 191 | 'my-ldap': { 192 | 'type': 'ldap', 193 | 'ldap': _my_ldap_config, 194 | 'mapping': { 195 | 'name': 'givenName', 196 | 'email': 'mail', 197 | 'affiliation': 'company' 198 | } 199 | }, 200 | 'my_shibboleth': { 201 | 'type': 'shibboleth', 202 | 'mapping': { 203 | 'email': 'ADFS_EMAIL', 204 | 'name': 'ADFS_FIRSTNAME', 205 | 'affiliation': 'ADFS_HOMEINSTITUTE' 206 | } 207 | }, 208 | 'saml': { 209 | 'type': 'saml', 210 | 'title': 'SAML', 211 | 'mapping': { 212 | 'name': 'DisplayName', 213 | 'email': 'EmailAddress', 214 | 'affiliation': 'HomeInstitute', 215 | }, 216 | # You can use a different field as the unique identifier. 217 | # By default the qualified NameID from SAML is used, but in 218 | # case you want to use something else, any SAML attribute can 219 | # be used. 220 | # 'identifier_field': 'Username' 221 | } 222 | } 223 | 224 | MULTIPASS_PROVIDER_MAP = { 225 | 'test_auth_provider': 'test_identity_provider', 226 | 'my-ldap': 'my-ldap', 227 | 'shib': 'my_shibboleth', 228 | # You can also be explicit (only needed for more complex links) 229 | 'github': [ 230 | { 231 | 'identity_provider': 'github' 232 | } 233 | ] 234 | } 235 | 236 | MULTIPASS_LOGIN_FORM_TEMPLATE = 'login_form.html' 237 | MULTIPASS_LOGIN_SELECTOR_TEMPLATE = 'login_selector.html' 238 | MULTIPASS_LOGIN_URLS = {'/my_login/', '/my_login/'} 239 | MULTIPASS_IDENTITY_INFO_KEYS = ['email', 'name', 'affiliation'] 240 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Flask-Multipass 3 | =============== 4 | 5 | .. module:: flask_multipass 6 | 7 | Flask-Multipass allows you to provide different authentication systems in 8 | your Flask application. It provides support for the most common systems 9 | and can be easily extended to support additional ones. 10 | 11 | User guide 12 | ---------- 13 | 14 | This part of the documentation will show you how to use 15 | Flask-Multipass with your Flask application. 16 | 17 | .. toctree:: 18 | :maxdepth: 4 19 | 20 | quickstart 21 | config_example 22 | 23 | API reference 24 | ------------- 25 | 26 | For more detailed information on classes and methods of Flask-Multipass 27 | you can checkout our API reference. 28 | 29 | .. toctree:: 30 | :maxdepth: 2 31 | 32 | api 33 | 34 | 35 | Changelog 36 | --------- 37 | List of released versions of Flask-Multipass can be found here. 38 | 39 | .. toctree:: 40 | :maxdepth: 2 41 | 42 | changelog 43 | -------------------------------------------------------------------------------- /example/example.cfg.example: -------------------------------------------------------------------------------- 1 | # Register your test application here: https://github.com/settings/applications/ 2 | 3 | _github_oauth_config = { 4 | 'client_id': '', # put your client id here 5 | 'client_secret': '', # put your client secret here 6 | 'authorize_url': 'https://github.com/login/oauth/authorize', 7 | 'access_token_url': 'https://github.com/login/oauth/access_token', 8 | 'api_base_url': 'https://api.github.com', 9 | 'userinfo_endpoint': '/user', 10 | } 11 | 12 | 13 | _my_ldap_config = { 14 | 'uri': 'ldaps://ldap.example.com:636', 15 | 'bind_dn': 'uid=admin,DC=example,DC=com', 16 | 'bind_password': 'p455w0rd', 17 | 'timeout': 30, 18 | 'verify_cert': True, 19 | # optional: if not present, uses certifi's CA bundle (if installed) 20 | 'cert_file': 'path/to/server/cert', 21 | 'starttls': False, 22 | 'page_size': 1000, 23 | 24 | 'uid': 'uid', 25 | 'user_base': 'OU=Users,DC=example,DC=com', 26 | 'user_filter': '(objectCategory=person)', 27 | 28 | 'gid': 'cn', 29 | 'group_base': 'OU=Organizational Units,DC=example,DC=com', 30 | 'group_filter': '(objectCategory=groupOfNames)', 31 | 'member_of_attr': 'memberOf', 32 | 'ad_group_style': False, 33 | } 34 | 35 | _my_saml_config = { 36 | # If enabled, errors are more verbose and received attributes are 37 | # printed to stdout 38 | # 'debug': True, 39 | 'sp': { 40 | # this needs to match what you use when registering the SAML SP 41 | 'entityId': 'multipass-saml-test', 42 | # these bindings usually do not need to be set manually; the URLs 43 | # are auto-generated 44 | # 'assertionConsumerService': {'binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'}, 45 | # 'singleLogoutService': {'binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'}, 46 | # depending on your security config you may need to generate a 47 | # certificate and private key. 48 | # you can use https://www.samltool.com/self_signed_certs.php or 49 | # use `openssl` for it (which is more secure as it ensures the 50 | # key never leaves your machine) 51 | 'x509cert': '', 52 | 'privateKey': '', 53 | # persistent name ids are the default, but you can override it 54 | # with a custom format 55 | # 'NameIDFormat': 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' 56 | }, 57 | 'idp': { 58 | # This metadata is provided by your SAML IdP. You can omit (or 59 | # leave empty) the whole 'idp' section in case you need SP 60 | # metadata to register your app and get the IdP metadata from 61 | # https://yourapp.example.com/multipass/saml/{auth-provider-name}/metadata 62 | # and then fill in the IdP metadata afterwards. 63 | 'entityId': 'https://idp.example.com', 64 | 'x509cert': '', 65 | 'singleSignOnService': { 66 | 'url': 'https://idp.example.com/saml', 67 | 'binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' 68 | }, 69 | # If you do not want to perform a SAML logout when logging out 70 | # from your application, you can omit this section 71 | 'singleLogoutService': { 72 | 'url': 'https://idp.example.com/saml', 73 | 'binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' 74 | } 75 | }, 76 | # These advanced settings allow you to tune the SAML security options. 77 | # Please see the documentation on https://github.com/onelogin/python3-saml 78 | # for details on how they behave. Note that by requiring signatures, 79 | # you usually need to set a cert and key on your SP config, but for 80 | # testing you may want to disable all signing-related options. 81 | 'security': { 82 | 'nameIdEncrypted': False, 83 | 'authnRequestsSigned': True, 84 | 'logoutRequestSigned': True, 85 | 'logoutResponseSigned': True, 86 | 'signMetadata': True, 87 | 'wantMessagesSigned': True, 88 | 'wantAssertionsSigned': True, 89 | 'wantNameId' : True, 90 | 'wantNameIdEncrypted': False, 91 | 'wantAssertionsEncrypted': False, 92 | 'allowSingleLabelDomains': False, 93 | 'signatureAlgorithm': 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256', 94 | 'digestAlgorithm': 'http://www.w3.org/2001/04/xmlenc#sha256' 95 | }, 96 | # You can set contact information which will be included in the SP 97 | # metadata; it is up to the IdP if/how this information is used: 98 | # 'contactPerson': { 99 | # 'technical': { 100 | # 'givenName': 'technical_name', 101 | # 'emailAddress': 'technical@example.com' 102 | # }, 103 | # 'support': { 104 | # 'givenName': 'support_name', 105 | # 'emailAddress': 'support@example.com' 106 | # } 107 | # }, 108 | # 'organization': { 109 | # 'en-US': { 110 | # 'name': 'sp_test', 111 | # 'displayname': 'SP test', 112 | # 'url': 'http://sp.example.com' 113 | # } 114 | # } 115 | } 116 | 117 | MULTIPASS_AUTH_PROVIDERS = { 118 | 'test_auth_provider': { 119 | 'type': 'static', 120 | 'title': 'Insecure dummy auth', 121 | 'identities': { 122 | 'Pig': 'pig123', 123 | 'Bunny': 'bunny123' 124 | } 125 | }, 126 | 'github': { 127 | 'type': 'authlib', 128 | 'title': 'GitHub', 129 | 'authlib_args': _github_oauth_config 130 | }, 131 | 'my-ldap': { 132 | 'type': 'ldap', 133 | 'title': 'My Organization LDAP', 134 | 'ldap': _my_ldap_config, 135 | }, 136 | 'shib': { 137 | 'type': 'shibboleth', 138 | 'title': 'Shibboleth SSO', 139 | 'callback_uri': '/shibboleth/sso', 140 | 'logout_uri': 'https://sso.example.com/logout' 141 | }, 142 | 'saml': { 143 | 'type': 'saml', 144 | 'title': 'SAML SSO', 145 | 'saml_config': _my_saml_config, 146 | # If your IdP is ADFS you may need to enable this. For details, see 147 | # https://github.com/onelogin/python-saml/pull/144 148 | # 'lowercase_urlencoding': True 149 | }, 150 | } 151 | 152 | MULTIPASS_IDENTITY_PROVIDERS = { 153 | 'test_identity_provider': { 154 | 'type': 'static', 155 | 'identities': { 156 | 'Pig': {'email': 'guinea.pig@example.com', 'name': 'Guinea Pig', 'affiliation': 'Pig University'}, 157 | 'Bunny': {'email': 'bugs.bunny@example.com', 'name': 'Bugs Bunny', 'affiliation': 'Bunny Inc.'} 158 | }, 159 | 'groups': { 160 | 'Admins': ['Pig'], 161 | 'Everybody': ['Pig', 'Bunny'], 162 | } 163 | }, 164 | 'github': { 165 | 'type': 'authlib', 166 | 'title': 'GitHub', 167 | 'identifier_field': 'id', 168 | 'mapping': { 169 | 'affiliation': 'company', 170 | 'first_name': 'name' 171 | } 172 | }, 173 | 'my-ldap': { 174 | 'type': 'ldap', 175 | 'ldap': _my_ldap_config, 176 | 'mapping': { 177 | 'name': 'givenName', 178 | 'email': 'mail', 179 | 'affiliation': 'company' 180 | } 181 | }, 182 | 'my_shibboleth': { 183 | 'type': 'shibboleth', 184 | 'mapping': { 185 | 'email': 'ADFS_EMAIL', 186 | 'name': 'ADFS_FIRSTNAME', 187 | 'affiliation': 'ADFS_HOMEINSTITUTE' 188 | } 189 | }, 190 | 'saml': { 191 | 'type': 'saml', 192 | 'title': 'SAML', 193 | 'mapping': { 194 | 'name': 'DisplayName', 195 | 'email': 'EmailAddress', 196 | 'affiliation': 'HomeInstitute', 197 | }, 198 | # You can use a different field as the unique identifier. 199 | # By default the qualified NameID from SAML is used, but in 200 | # case you want to use something else, any SAML attribute can 201 | # be used. 202 | # 'identifier_field': 'Username' 203 | } 204 | } 205 | 206 | MULTIPASS_PROVIDER_MAP = { 207 | 'test_auth_provider': 'test_identity_provider', 208 | 'my-ldap': 'my-ldap', 209 | 'shib': 'my_shibboleth', 210 | # You can also be explicit (only needed for more complex links) 211 | 'github': [ 212 | { 213 | 'identity_provider': 'github' 214 | } 215 | ] 216 | } 217 | 218 | MULTIPASS_LOGIN_FORM_TEMPLATE = 'login_form.html' 219 | MULTIPASS_LOGIN_SELECTOR_TEMPLATE = 'login_selector.html' 220 | MULTIPASS_IDENTITY_INFO_KEYS = ['email', 'name', 'affiliation'] 221 | WTF_CSRF_ENABLED = False 222 | SQLALCHEMY_DATABASE_URI = 'sqlite:////tmp/multipass.db' 223 | -------------------------------------------------------------------------------- /example/example.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | import json 8 | 9 | from flask import Flask, flash, g, redirect, render_template, request, session, url_for 10 | from flask_sqlalchemy import SQLAlchemy 11 | 12 | from flask_multipass import Multipass 13 | from flask_multipass.providers.sqlalchemy import SQLAlchemyAuthProviderBase, SQLAlchemyIdentityProviderBase 14 | 15 | application = app = Flask(__name__) 16 | app.debug = True 17 | app.secret_key = 'fma-example' 18 | db = SQLAlchemy() 19 | multipass = Multipass() 20 | 21 | 22 | class User(db.Model): 23 | __tablename__ = 'users' 24 | 25 | id = db.Column(db.Integer, primary_key=True) 26 | name = db.Column(db.String) 27 | email = db.Column(db.String) 28 | affiliation = db.Column(db.String) 29 | 30 | 31 | class Identity(db.Model): 32 | __tablename__ = 'identities' 33 | 34 | id = db.Column(db.Integer, primary_key=True) 35 | user_id = db.Column(db.Integer, db.ForeignKey('users.id')) 36 | provider = db.Column(db.String) 37 | identifier = db.Column(db.String) 38 | multipass_data = db.Column(db.Text) 39 | password = db.Column(db.String) 40 | user = db.relationship(User, backref='identities') 41 | 42 | @property 43 | def provider_impl(self): 44 | return multipass.identity_providers[self.provider] 45 | 46 | 47 | class LocalAuthProvider(SQLAlchemyAuthProviderBase): 48 | identity_model = Identity 49 | provider_column = Identity.provider 50 | identifier_column = Identity.identifier 51 | 52 | def check_password(self, identity, password): 53 | return identity.password == password 54 | 55 | 56 | class LocalIdentityProvider(SQLAlchemyIdentityProviderBase): 57 | user_model = User 58 | identity_user_relationship = Identity.user 59 | 60 | 61 | @multipass.identity_handler 62 | def identity_handler(identity_info): 63 | identity = Identity.query.filter_by(provider=identity_info.provider.name, 64 | identifier=identity_info.identifier).first() 65 | if not identity: 66 | user = User.query.filter_by(email=identity_info.data['email']).first() 67 | if not user: 68 | user = User(**identity_info.data.to_dict()) 69 | db.session.add(user) 70 | identity = Identity(provider=identity_info.provider.name, identifier=identity_info.identifier) 71 | user.identities.append(identity) 72 | else: 73 | user = identity.user 74 | identity.multipass_data = json.dumps(identity_info.multipass_data) 75 | db.session.commit() 76 | session['user_id'] = user.id 77 | flash(f'Received IdentityInfo: {identity_info}', 'success') 78 | 79 | 80 | @app.before_request 81 | def load_user_from_session(): 82 | g.user = None 83 | if 'user_id' in session: 84 | g.user = User.query.get(session['user_id']) 85 | 86 | 87 | @app.route('/') 88 | def index(): 89 | results = None 90 | if request.args.get('search') == 'identities': 91 | exact = 'exact' in request.args 92 | criteria = {} 93 | if request.args['email']: 94 | criteria['email'] = request.args['email'] 95 | if request.args['name']: 96 | criteria['name'] = request.args['name'] 97 | results = list(multipass.search_identities(exact=exact, **criteria)) 98 | elif request.args.get('search') == 'groups': 99 | exact = 'exact' in request.args 100 | results = list(multipass.search_groups(exact=exact, name=request.args['name'])) 101 | return render_template('index.html', results=results) 102 | 103 | 104 | @app.route('/group///') 105 | def group(provider, name): 106 | group = multipass.get_group(provider, name) 107 | if group is None: 108 | flash('No such group', 'error') 109 | return redirect(url_for('index')) 110 | return render_template('group.html', group=group) 111 | 112 | 113 | @app.route('/logout') 114 | def logout(): 115 | flash('Logged out', 'success') 116 | return multipass.logout(url_for('index'), clear_session=True) 117 | 118 | 119 | @app.route('/refresh') 120 | def refresh(): 121 | if not g.user: 122 | flash('Not logged in', 'error') 123 | return redirect(url_for('index')) 124 | for identity in g.user.identities: 125 | if json.loads(identity.multipass_data) is None: 126 | continue 127 | identity_info = multipass.refresh_identity(identity.identifier, json.loads(identity.multipass_data)) 128 | identity.multipass_data = json.dumps(identity_info.multipass_data) 129 | flash(f'Refreshed IdentityInfo: {identity_info}', 'success') 130 | db.session.commit() 131 | return redirect(url_for('index')) 132 | 133 | 134 | app.config.from_pyfile('example.cfg') 135 | multipass.register_provider(LocalAuthProvider, 'example_local') 136 | multipass.register_provider(LocalIdentityProvider, 'example_local') 137 | multipass.init_app(app) 138 | db.init_app(app) 139 | with app.app_context(): 140 | db.create_all() 141 | if not User.query.filter_by(name='Local Guinea Pig').count(): 142 | user = User(name='Local Guinea Pig', email='test@example.com', affiliation='Local') 143 | identity = Identity(provider='local', identifier='Test', multipass_data='null', password='123') 144 | user.identities.append(identity) 145 | db.session.add(user) 146 | db.session.commit() 147 | 148 | 149 | if __name__ == '__main__': 150 | app.run('0.0.0.0', 10500, use_evalex=False) 151 | -------------------------------------------------------------------------------- /example/requirements.txt: -------------------------------------------------------------------------------- 1 | authlib 2 | flask-sqlalchemy 3 | flask-wtf 4 | python-ldap 5 | -------------------------------------------------------------------------------- /example/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Flask-Multipass example 6 | 7 | 8 | {% with messages = get_flashed_messages(with_categories=True) %} 9 | {% if messages %} 10 |
    11 | {%- for category, message in messages %} 12 |
  • {{ category }}: {{ message }}
  • 13 | {% endfor -%} 14 |
15 | {% endif %} 16 | {% endwith %} 17 | 18 | {% block content %}{% endblock %} 19 | 20 | 21 | -------------------------------------------------------------------------------- /example/templates/group.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% block content %} 3 | Group: {{ group.name }} (from provider {{ group.provider.title }}) 4 |
5 | Members: 6 | {% if group.supports_member_list %} 7 |
    8 | {% for member in group|sort(attribute='identifier') %} 9 |
  • {{ member }}
  • 10 | {% endfor %} 11 |
12 | {% else %} 13 | Not available 14 | {% endif %} 15 |
16 | {% if g.user %} 17 | {% for identity in g.user.identities if identity.provider == group.provider.name %} 18 | You are a group member: {% if identity.identifier in group %}YES{% else %}NO{% endif %} 19 | {% endfor %} 20 | {% endif %} 21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /example/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% block content %} 3 | Log in 4 | {% if g.user %} 5 |
6 | Refresh 7 |
8 | Log out 9 |
10 | Logged in as {{ g.user.name }} 11 | {% endif %} 12 | {% if g.user %} 13 |
14 |

User identities

15 |
    16 | {% for identity in g.user.identities %} 17 | {% set groups = (identity.provider_impl.supports_get_identity_groups and 18 | identity.provider_impl.get_identity_groups(identity.identifier)) %} 19 |
  • 20 | {{ identity.provider }}: {{ identity.identifier }} 21 | {% if groups %} 22 | [{{ groups|sort(attribute='name')|join(', ', attribute='name') }}] 23 | {% endif %} 24 |
  • 25 | {% endfor %} 26 |
27 | {% endif %} 28 |
29 |

Search identities (users)

30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 |
38 | {% if request.args.search == 'identities' %} 39 |

Results

40 |
    41 | {% for result in results|sort(attribute='identifier') %} 42 |
  • {{ result }}
  • 43 | {% endfor %} 44 |
45 | {% endif %} 46 |
47 |
48 |

Search groups

49 |
50 | 51 | 52 | 53 | 54 | 55 |
56 | {% if request.args.search == 'groups' %} 57 |

Results

58 |
    59 | {% for result in results|sort(attribute='name') %} 60 |
  • {{ result }}
  • 61 | {% endfor %} 62 |
63 | {% endif %} 64 |
65 | {% endblock %} 66 | -------------------------------------------------------------------------------- /example/templates/login_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% block content %} 3 |

Log in using {{ provider.title }}

4 | 5 |
6 | {% for field in form %} 7 | {% if field.widget.__class__.__name__ == 'HiddenInput' %} 8 | {{ field() }} 9 | {% else %} 10 |
11 | {{ field.label() }} 12 | {{ field() }} 13 | {% if field.errors %} 14 | Errors: {{ field.errors|join(' / ') }} 15 | {% endif %} 16 |
17 | {% endif %} 18 | {% endfor %} 19 | 20 |
21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /example/templates/login_selector.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% block content %} 3 | Available login providers: 4 |
    5 | {% for provider in providers|sort(attribute='title') %} 6 |
  • {{ provider.title }}
  • 7 | {% endfor %} 8 |
9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /flask_multipass/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | from flask_multipass.auth import AuthProvider 8 | from flask_multipass.core import Multipass 9 | from flask_multipass.data import AuthInfo, IdentityInfo 10 | from flask_multipass.exceptions import ( 11 | AuthenticationFailed, 12 | GroupRetrievalFailed, 13 | IdentityRetrievalFailed, 14 | InvalidCredentials, 15 | MultipassException, 16 | NoSuchUser, 17 | ) 18 | from flask_multipass.group import Group 19 | from flask_multipass.identity import IdentityProvider 20 | 21 | __all__ = ('Multipass', 'AuthProvider', 'IdentityProvider', 'AuthInfo', 'IdentityInfo', 'Group', 'MultipassException', 22 | 'AuthenticationFailed', 'IdentityRetrievalFailed', 'GroupRetrievalFailed', 'NoSuchUser', 23 | 'InvalidCredentials') 24 | 25 | 26 | def __getattr__(name): 27 | if name == '__version__': 28 | import importlib.metadata 29 | import warnings 30 | 31 | warnings.warn( 32 | 'The `__version__` attribute is deprecated. Use feature detection or' 33 | " `importlib.metadata.version('flask-multipass')` instead.", 34 | DeprecationWarning, 35 | stacklevel=2, 36 | ) 37 | return importlib.metadata.version('flask-multipass') 38 | 39 | raise AttributeError(name) 40 | -------------------------------------------------------------------------------- /flask_multipass/auth.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | from flask_multipass.util import SupportsMeta 8 | 9 | 10 | class AuthProvider(metaclass=SupportsMeta): 11 | """Provides the base for an authentication provider. 12 | 13 | :param multipass: The Flask-Multipass instance 14 | :param name: The name of this auth provider instance 15 | :param settings: The settings dictionary for this auth provider 16 | instance 17 | """ 18 | 19 | __support_attrs__ = { 20 | SupportsMeta.callable(lambda cls: cls.login_form is not None, 21 | 'login_form is set'): 'process_local_login', 22 | SupportsMeta.callable(lambda cls: cls.login_form is None, 23 | 'login_form is not set'): 'initiate_external_login', 24 | } 25 | #: The entry point to lookup providers (do not override this!) 26 | _entry_point = 'flask_multipass.auth_providers' 27 | #: If there may be multiple instances of this auth provider 28 | multi_instance = True 29 | #: If this auth provider requires the user to enter data using a 30 | #: form in your application, specify a :class:`~flask_wtf.Form` 31 | #: here (usually containing a username/email and a password field). 32 | login_form = None 33 | 34 | def __init__(self, multipass, name, settings): 35 | self.multipass = multipass 36 | self.name = name 37 | self.settings = settings.copy() 38 | self.title = self.settings.pop('title', self.name) 39 | 40 | @property 41 | def is_external(self): 42 | """True if the provider is external. 43 | 44 | External providers do not have a login form and instead 45 | redirect to a third-party service to perform authentication. 46 | """ 47 | return self.login_form is None 48 | 49 | def process_local_login(self, data): # pragma: no cover 50 | """Handles the login process based on form data. 51 | 52 | Only called if the login form validates successfully. 53 | This method needs to verify the form data actually contains 54 | valid credentials. 55 | 56 | After successful authentication this method needs to call 57 | :meth:`.Multipass.handle_auth_success` with an :class:`.AuthInfo` 58 | instance containing data that can be used by the identity provider 59 | to retrieve information for that user. 60 | 61 | :param data: The form data (as returned by the `data` attribute 62 | of the :obj:`login_form` instance) 63 | :return: The return value of :meth:`.Multipass.handle_auth_success` 64 | """ 65 | if not self.is_external: 66 | raise NotImplementedError 67 | else: 68 | raise RuntimeError('This provider has no login form') 69 | 70 | def initiate_external_login(self): # pragma: no cover 71 | """Initiates the login process for external authentication. 72 | 73 | Called when the provider is selected and has no login form. 74 | This method usually redirects to an external login page. 75 | 76 | Executing this method eventually needs to result in a call to 77 | :meth:`.Multipass.handle_auth_success` with an :class:`.AuthInfo` 78 | instance containing data that can be used by the identity provider 79 | to retrieve information for that user. 80 | 81 | The most common way to achieve this is registering a new 82 | endpoint (decorated with :func:`.login_view`) and passing the 83 | URL of that endpoint to the external provider so it redirects 84 | to it once the user authenticated with that provider. 85 | 86 | :return: A Flask :class:`~flask.Response`, usually created by 87 | :func:`~flask.redirect` 88 | """ 89 | if self.is_external: 90 | raise NotImplementedError 91 | else: 92 | raise RuntimeError('This provider uses a login form') 93 | 94 | def process_logout(self, return_url): 95 | """Handles logging out from the provider. 96 | 97 | This is only necessary if logging out from the application 98 | needs to perform some provider-specific action such as sending 99 | a logout notification to the provider or redirecting to a SSO 100 | logout page. 101 | 102 | If a value is returned, it's eventually returned to Flask as a 103 | view function return value, so anything that's valid there can 104 | be used. Most likely you want to use :func:`~flask.redirect` 105 | to redirect to an external logout page though. 106 | 107 | When redirecting to an external site, you should pass along the 108 | `return_url` if the external provider allows you to specify a 109 | URL to redirect to after logging out. 110 | 111 | :param return_url: The URL to redirect to after logging our. 112 | :return: ``None`` or a Flask response. 113 | """ 114 | return None 115 | 116 | def __repr__(self): 117 | return f'<{type(self).__name__}({self.name})>' 118 | -------------------------------------------------------------------------------- /flask_multipass/data.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | from werkzeug.datastructures import MultiDict 8 | 9 | from flask_multipass.util import convert_provider_data 10 | 11 | 12 | class AuthInfo: 13 | """Stores data from an authentication provider. 14 | 15 | :param provider: The authentication provider instance providing 16 | the data. 17 | :param secure_login: A bool indicating whether the login was secure, 18 | with the interpretation of "secure" being up to 19 | the application and auth provider, but generally 20 | this is meant to be set if multiple factors have 21 | been used. Must be left as ``None`` in case no 22 | information about how secure the login was i 23 | available. 24 | :param data: Any data the authentication provider wants to pass on 25 | to identity providers. This data must allow any 26 | connected identity provider to uniquely identify a user. 27 | """ 28 | 29 | def __init__(self, provider, secure_login=None, **data): 30 | self.provider = provider 31 | self.secure_login = secure_login 32 | self.data = data 33 | if not data: 34 | raise ValueError('data cannot be empty') 35 | is_secure_login = provider.settings.get('is_secure_login') 36 | if is_secure_login is not None: 37 | self.secure_login = is_secure_login(self) 38 | 39 | def map(self, mapping): 40 | """Creates a new instance with transformed data keys. 41 | 42 | :param mapping: The dict mapping the current data keys to the 43 | the keys that are expected by the identity 44 | provider. Any key that is not in `mapping` is 45 | kept as-is. 46 | """ 47 | missing_keys = set(mapping.values()) - set(self.data) 48 | if missing_keys: 49 | raise KeyError(next(iter(missing_keys))) 50 | return AuthInfo(self.provider, **convert_provider_data(self.data, mapping)) 51 | 52 | def __repr__(self): 53 | data = ', '.join(f'{k}={v!r}' for k, v in sorted(self.data.items())) 54 | secure = f', secure={self.secure_login}' if self.secure_login is not None else '' 55 | return f'' 56 | 57 | 58 | class IdentityInfo: 59 | """Stores user identity information for the application. 60 | 61 | :param provider: The identity provider instance providing the data. 62 | :param identifier: A unique identifier string that can later be 63 | used to retrieve identity information for the 64 | same user. 65 | :param multipass_data: A dict containing additional data the 66 | identity provider needs e.g. to refresh the 67 | identity information for the same user, 68 | without him authenticating again by keeping a 69 | long-lived token. 70 | :param secure_login: A bool indicating whether the login was secure, 71 | with the interpretation of "secure" being up to 72 | the application and auth provider, but generally 73 | this is meant to be set if multiple factors have 74 | been used. Must be left as ``None`` in case the 75 | object has not been created during a login process 76 | or if no information about how secure the login was 77 | is available. 78 | :param data: Any data the identity provider wants to pass on the 79 | application. 80 | """ 81 | 82 | def __init__(self, provider, identifier, multipass_data=None, secure_login=None, **data): 83 | self.provider = provider 84 | self.secure_login = secure_login 85 | # XXX: do we ever expect something that's not a str here?! 86 | self.identifier = identifier.decode() if isinstance(identifier, bytes) else str(identifier) 87 | if not provider.supports_refresh: 88 | assert multipass_data is None 89 | self.multipass_data = None 90 | else: 91 | self.multipass_data = dict(multipass_data or {}, _provider=provider.name) 92 | mapping = provider.settings.get('mapping') 93 | self.data = MultiDict(convert_provider_data(data, mapping or {}, self.provider.settings['identity_info_keys'])) 94 | 95 | def __repr__(self): 96 | data = ', '.join(f'{k}={v!r}' for k, v in sorted(self.data.items())) 97 | secure = f', secure={self.secure_login}' if self.secure_login is not None else '' 98 | return f'' 99 | -------------------------------------------------------------------------------- /flask_multipass/exceptions.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | class MultipassException(Exception): 8 | """Base class for Multipass exceptions.""" 9 | 10 | def __init__(self, message=None, details=None, provider=None): 11 | args = (message,) if message else () 12 | Exception.__init__(self, *args) 13 | self.details = details 14 | self.provider = provider 15 | 16 | 17 | class AuthenticationFailed(MultipassException): 18 | """ 19 | Indicates an authentication failure that was caused by the user, 20 | e.g. by entering the wrong credentials or not authorizing the 21 | application. 22 | """ 23 | 24 | 25 | class NoSuchUser(AuthenticationFailed): 26 | """Indicates a user does not exist when attempting to authenticate.""" 27 | 28 | def __init__(self, message='No such user', *, details=None, provider=None, identifier=None): 29 | AuthenticationFailed.__init__(self, message, details=details, provider=provider) 30 | self.identifier = identifier 31 | 32 | 33 | class InvalidCredentials(AuthenticationFailed): 34 | """Indicates a failure to authenticate using the given credentials.""" 35 | 36 | def __init__(self, message='Invalid credentials', *, details=None, provider=None, identifier=None): 37 | AuthenticationFailed.__init__(self, message, details=details, provider=provider) 38 | self.identifier = identifier 39 | 40 | 41 | class IdentityRetrievalFailed(MultipassException): 42 | """Indicates a failure while retrieving identity information.""" 43 | 44 | 45 | class GroupRetrievalFailed(MultipassException): 46 | """Indicates a failure while retrieving group information.""" 47 | -------------------------------------------------------------------------------- /flask_multipass/group.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | from flask_multipass.util import SupportsMeta 8 | 9 | 10 | class Group(metaclass=SupportsMeta): 11 | """Base class for groups. 12 | 13 | :param provider: The identity provider managing the group. 14 | :param name: The unique name of the group. 15 | """ 16 | 17 | __support_attrs__ = {'supports_member_list': 'get_members'} 18 | #: If it is possible to get the list of members of a group. 19 | supports_member_list = False 20 | 21 | def __init__(self, provider, name): # pragma: no cover 22 | self.provider = provider 23 | self.name = name 24 | 25 | def get_members(self): # pragma: no cover 26 | """Returns the members of the group. 27 | 28 | This can also be performed by iterating over the group. 29 | If the group does not support listing members, 30 | :exc:`~exceptions.NotImplementedError` is raised. 31 | 32 | :return: An iterator of :class:`.IdentityInfo` objects. 33 | """ 34 | if self.supports_member_list: 35 | raise NotImplementedError 36 | 37 | def has_member(self, identifier): # pragma: no cover 38 | """Checks if a given identity is a member of the group. 39 | 40 | This check can also be performed using the ``in`` operator. 41 | 42 | :param identifier: The `identifier` from an :class:`.IdentityInfo` 43 | provided by the associated identity provider. 44 | """ 45 | raise NotImplementedError 46 | 47 | def __iter__(self): # pragma: no cover 48 | return self.get_members() 49 | 50 | def __contains__(self, identifier): # pragma: no cover 51 | return self.has_member(identifier) 52 | 53 | def __repr__(self): 54 | return f'<{type(self).__name__}({self.provider}, {self.name})>' 55 | -------------------------------------------------------------------------------- /flask_multipass/identity.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | from flask import current_app 8 | 9 | from flask_multipass.util import SupportsMeta, convert_app_data 10 | 11 | 12 | class IdentityProvider(metaclass=SupportsMeta): 13 | """Provides the base for an identity provider. 14 | 15 | :param multipass: The Flask-Multipass instance 16 | :param name: The name of this identity provider instance 17 | :param settings: The settings dictionary for this identity 18 | provider instance 19 | """ 20 | 21 | __support_attrs__ = {'supports_refresh': 'refresh_identity', 22 | 'supports_get': 'get_identity', 23 | 'supports_search': 'search_identities', 24 | 'supports_search_ex': 'search_identities_ex', 25 | 'supports_groups': ('get_group', 'search_groups', 'group_class'), 26 | 'supports_get_identity_groups': 'get_identity_groups'} 27 | #: The entry point to lookup providers (do not override this!) 28 | _entry_point = 'flask_multipass.identity_providers' 29 | #: If there may be multiple instances of this identity provider 30 | multi_instance = True 31 | #: If the provider supports refreshing identity information 32 | supports_refresh = False 33 | #: If the provider supports getting identity information based from 34 | #: an identifier 35 | supports_get = True 36 | #: If the provider supports searching identities 37 | supports_search = False 38 | #: If the provider supports the extended identity search feature 39 | supports_search_ex = False 40 | #: If the provider also provides groups and membership information 41 | supports_groups = False 42 | #: If the provider supports getting the list of groups an identity belongs to 43 | supports_get_identity_groups = False 44 | #: The class that represents groups from this provider. Must be a 45 | #: subclass of :class:`.Group` 46 | group_class = None 47 | 48 | def __init__(self, multipass, name, settings): 49 | self.multipass = multipass 50 | self.name = name 51 | self.settings = settings.copy() 52 | self.settings.setdefault('identity_info_keys', current_app.config['MULTIPASS_IDENTITY_INFO_KEYS']) 53 | self.settings.setdefault('mapping', {}) 54 | self.title = self.settings.pop('title', self.name) 55 | search_enabled = self.settings.pop('search_enabled', self.supports_search) 56 | if search_enabled and not self.supports_search: 57 | raise ValueError('Provider does not support searching: ' + type(self).__name__) 58 | self.supports_search = search_enabled 59 | if not self.supports_search: 60 | self.supports_search_ex = False 61 | 62 | def get_identity_from_auth(self, auth_info): # pragma: no cover 63 | """Retrieves identity information after authentication. 64 | 65 | :param auth_info: An :class:`.AuthInfo` instance from an auth 66 | provider 67 | :return: An :class:`.IdentityInfo` instance containing identity 68 | information or ``None`` if no identity was found 69 | """ 70 | raise NotImplementedError 71 | 72 | def refresh_identity(self, identifier, multipass_data): # pragma: no cover 73 | """Retrieves identity information for an existing user identity. 74 | 75 | This method returns identity information for an identity that 76 | has been retrieved before based on the provider-specific refresh 77 | data. 78 | 79 | :param identifier: The `identifier` from :class:`.IdentityInfo` 80 | :param multipass_data: The `multipass_data` dict from 81 | :class:`.IdentityInfo` 82 | """ 83 | if self.supports_refresh: 84 | raise NotImplementedError 85 | 86 | def get_identity(self, identifier): # pragma: no cover 87 | """Retrieves identity information. 88 | 89 | This method is similar to :meth:`refresh_identity` but does 90 | not require `multiauth_data` 91 | 92 | :param identifier: The unique user identifier used by the 93 | provider. 94 | :return: An :class:`.IdentityInfo` instance or ``None`` if the 95 | identity does not exist. 96 | """ 97 | if self.supports_get: 98 | raise NotImplementedError 99 | else: 100 | raise RuntimeError('This provider does not support getting an identity based on the identifier') 101 | 102 | def get_identity_groups(self, identifier): # pragma: no cover 103 | """Retrieves the list of groups a user identity belongs to. 104 | 105 | :param identifier: The unique user identifier used by the 106 | provider. 107 | :return: A set of groups 108 | """ 109 | if self.supports_get_identity_groups: 110 | raise NotImplementedError 111 | else: 112 | raise RuntimeError('This provider does not support getting the list of groups for an identity') 113 | 114 | def search_identities(self, criteria, exact=False): # pragma: no cover 115 | """Searches user identities matching certain criteria. 116 | 117 | :param criteria: A dict containing the criteria to search for. 118 | :param exact: If criteria need to match exactly, i.e. no 119 | substring matches are performed. 120 | :return: An iterable of matching identities. 121 | """ 122 | if self.supports_search: 123 | raise NotImplementedError 124 | else: 125 | raise RuntimeError('This provider does not support searching') 126 | 127 | def search_identities_ex(self, criteria, exact=False, limit=None): # pragma: no cover 128 | """Search user identities matching certain criteria. 129 | 130 | :param criteria: A dict containing the criteria to search for. 131 | :param exact: If criteria need to match exactly, i.e. no 132 | substring matches are performed. 133 | :param limit: The max number of identities to return. 134 | :return: A tuple containing ``(identities, total_count)``. 135 | """ 136 | if self.supports_search_ex: 137 | raise NotImplementedError 138 | else: 139 | raise RuntimeError('This provider does not support extended searching') 140 | 141 | def get_group(self, name): # pragma: no cover 142 | """Returns a specific group. 143 | 144 | :param name: The name of the group 145 | :return: An instance of :attr:`group_class` 146 | """ 147 | if self.supports_groups: 148 | raise NotImplementedError 149 | else: 150 | raise RuntimeError('This provider does not provide groups') 151 | 152 | def search_groups(self, name, exact=False): # pragma: no cover 153 | """Searches groups by name. 154 | 155 | :param name: The name to search for 156 | :param exact: If the name needs to match exactly, i.e. no 157 | substring matches are performed 158 | :return: An iterable of matching :attr:`group_class` objects 159 | """ 160 | if self.supports_groups: 161 | raise NotImplementedError 162 | else: 163 | raise RuntimeError('This provider does not provide groups') 164 | 165 | def map_search_criteria(self, criteria): 166 | """Maps the search criteria from application keys to provider keys. 167 | 168 | :param criteria: A dict containing search criteria 169 | :return: A dict containing search criteria with mapped keys 170 | """ 171 | mapping = self.settings['mapping'] 172 | return convert_app_data(criteria, mapping) 173 | 174 | def __repr__(self): 175 | return f'<{type(self).__name__}({self.name})>' 176 | -------------------------------------------------------------------------------- /flask_multipass/providers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indico/flask-multipass/e44bab00f8c1fab2a57063670ebcf60ee723d9b5/flask_multipass/providers/__init__.py -------------------------------------------------------------------------------- /flask_multipass/providers/authlib.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the MIT License. 6 | 7 | import logging 8 | from urllib.parse import urlencode, urljoin 9 | 10 | from authlib.common.errors import AuthlibBaseError 11 | from authlib.integrations.flask_client import FlaskIntegration, OAuth 12 | from flask import current_app, redirect, request, session, url_for 13 | from requests.exceptions import HTTPError, RequestException, Timeout 14 | 15 | from flask_multipass.auth import AuthProvider 16 | from flask_multipass.data import AuthInfo, IdentityInfo 17 | from flask_multipass.exceptions import AuthenticationFailed, IdentityRetrievalFailed, MultipassException 18 | from flask_multipass.identity import IdentityProvider 19 | from flask_multipass.util import login_view 20 | 21 | # jwt/oidc-specific fields that are not relevant to applications 22 | INTERNAL_FIELDS = ('nonce', 'session_state', 'acr', 'jti', 'exp', 'azp', 'iss', 'iat', 'auth_time', 'typ', 'nbf', 'aud') 23 | 24 | _notset = object() 25 | 26 | 27 | class _MultipassFlaskIntegration(FlaskIntegration): 28 | @staticmethod 29 | def load_config(oauth, name, params): 30 | # we do not support loading anything directly from the flask config 31 | return {} 32 | 33 | 34 | class _MultipassOAuth(OAuth): 35 | framework_integration_cls = _MultipassFlaskIntegration 36 | 37 | def init_app(self, app, cache=None, fetch_token=None, update_token=None): 38 | # we do not use any of the flask extension functionality nor the registry 39 | # and do not want to prevent the main application from using it 40 | pass 41 | 42 | 43 | _authlib_oauth = _MultipassOAuth('dummy') 44 | 45 | 46 | class AuthlibAuthProvider(AuthProvider): 47 | """Provide authentication using Authlib (OAuth/OIDC). 48 | 49 | The type name to instantiate this provider is ``authlib``. 50 | 51 | The following settings are supported: 52 | 53 | - ``callback_uri``: the relative uri used after a successful oauth login. 54 | defaults to ``/multipass/authlib/``, but you can 55 | change it e.g. if your oauth/oidc infrastructure requires 56 | a specific callback uri and you do not want to rely on the 57 | default one. 58 | - ``include_token``: when set to ``True``, the AuthInfo passed to the 59 | identity provider includes the ``token`` containing 60 | the raw token data received from oauth. this is useful 61 | when connecting this auth provider to a custom identity 62 | provider that needs to do more than just calling the 63 | userinfo endpoint. 64 | when set to `'only'`, the AuthInfo will *only* contain 65 | the token, and no other data will be retrieved from the 66 | id token or userinfo endpoint. 67 | - ``use_id_token``: specify whether to use the OIDC id token instead of 68 | calling the userinfo endpoint. if unspecified or None, 69 | it will default to true when the ``openid`` scope is 70 | enabled (which indicates that OIDC is being used) 71 | - ``authlib_args``: a dict of params forwarded to authlib. see the arguments 72 | of ``register()`` in the 73 | `authlib docs `_ 74 | for details. 75 | - ``logout_uri``: a custom URL to redirect to after logging out; can be set to 76 | ``None`` to avoid using the URL from the OIDC metadata 77 | - ``logout_args``: the special argument types to include in the query string of 78 | the logout uri. defaults to 79 | ``{'client_id', 'id_token_hint', 'post_logout_redirect_uri'}`` 80 | - ``request_timeout``: the timeout in seconds for fetching the oauth token and 81 | requesting data from the userinfo endpoint (10 by default, 82 | set to None to disable) 83 | """ 84 | 85 | def __init__(self, *args, **kwargs): 86 | super().__init__(*args, **kwargs) 87 | callback_uri = self.settings.get('callback_uri', f'/multipass/authlib/{self.name}') 88 | self.authlib_client = _authlib_oauth.register(self.name, **self.authlib_settings) 89 | self.include_token = self.settings.get('include_token', False) 90 | self.request_timeout = self.settings.get('request_timeout') 91 | self.use_id_token = self.settings.get('use_id_token') 92 | self.logout_uri = self.settings.get('logout_uri', self.authlib_settings.get('logout_uri', _notset)) 93 | self.logout_args = self.settings.get('logout_args', {'client_id', 'id_token_hint', 'post_logout_redirect_uri'}) 94 | if self.use_id_token is None: 95 | # default to using the id token when using the openid scope (oidc) 96 | client_kwargs = self.authlib_settings.get('client_kwargs', {}) 97 | scopes = client_kwargs.get('scope', '').split() 98 | self.use_id_token = 'openid' in scopes 99 | self.authorized_endpoint = '_flaskmultipass_authlib_' + self.name 100 | current_app.add_url_rule(callback_uri, self.authorized_endpoint, self._authorize_callback, 101 | methods=('GET', 'POST')) 102 | 103 | @property 104 | def authlib_settings(self): 105 | return self.settings['authlib_args'] 106 | 107 | @property 108 | def _id_token_key(self): 109 | return f'_multipass_authlib_id_token:{self.name}' 110 | 111 | def _get_redirect_uri(self): 112 | return url_for(self.authorized_endpoint, _external=True) 113 | 114 | def initiate_external_login(self): 115 | try: 116 | return self.authlib_client.authorize_redirect(self._get_redirect_uri()) 117 | except RequestException: 118 | logging.getLogger('multipass.authlib').exception('Initiating Authlib login failed') 119 | multipass_exc = AuthenticationFailed('Logging in is currently not possible due to an error', provider=self) 120 | return self.multipass.handle_auth_error(multipass_exc, True) 121 | 122 | def process_logout(self, return_url): 123 | logout_uri = ( 124 | self.authlib_client.load_server_metadata().get('end_session_endpoint') 125 | if self.logout_uri is _notset 126 | else self.logout_uri 127 | ) 128 | if not logout_uri: 129 | return 130 | return_url = urljoin(request.url_root, return_url) 131 | client_id = self.authlib_settings['client_id'] 132 | query_args = {} 133 | if 'client_id' in self.logout_args: 134 | query_args['client_id'] = client_id 135 | if 'post_logout_redirect_uri' in self.logout_args: 136 | query_args['post_logout_redirect_uri'] = return_url 137 | if (id_token := session.pop(self._id_token_key, None)) and 'id_token_hint' in self.logout_args: 138 | query_args['id_token_hint'] = id_token 139 | query = urlencode(query_args) 140 | return redirect((logout_uri + '?' + query) if query else logout_uri) 141 | 142 | @login_view 143 | def _authorize_callback(self): 144 | # if authorization failed abort early 145 | error = request.args.get('error') 146 | if error: 147 | raise AuthenticationFailed(error, provider=self) 148 | try: 149 | try: 150 | token_data = self.authlib_client.authorize_access_token(timeout=self.request_timeout) 151 | except Timeout as exc: 152 | logging.getLogger('multipass.authlib').error('Getting token timed out') 153 | raise MultipassException('Token request timed out, please try again later') from exc 154 | except HTTPError as exc: 155 | try: 156 | data = exc.response.json() 157 | except ValueError: 158 | data = {'error': 'unknown', 'error_description': exc.response.text} 159 | error = data.get('error', 'unknown') 160 | desc = data.get('error_description', repr(data)) 161 | logging.getLogger('multipass.authlib').error(f'Getting token failed: {error}: %s', desc) 162 | raise 163 | authinfo_token_data = {} 164 | if (id_token := token_data.get('id_token')) and 'id_token_hint' in self.logout_args: 165 | session[self._id_token_key] = id_token 166 | if self.include_token == 'only': # noqa: S105 167 | return self.multipass.handle_auth_success(AuthInfo(self, token=token_data)) 168 | elif self.include_token: 169 | authinfo_token_data['token'] = token_data 170 | 171 | if self.use_id_token: 172 | try: 173 | # authlib 1.0+ parses the id_token automatically 174 | id_token = dict(token_data['userinfo']) 175 | except KeyError: 176 | # older authlib versions 177 | id_token = self.authlib_client.parse_id_token(token_data) 178 | for key in INTERNAL_FIELDS: 179 | id_token.pop(key, None) 180 | return self.multipass.handle_auth_success(AuthInfo(self, **dict(authinfo_token_data, **id_token))) 181 | else: 182 | user_info = self.authlib_client.userinfo(token=token_data) 183 | return self.multipass.handle_auth_success(AuthInfo(self, **dict(authinfo_token_data, **user_info))) 184 | except AuthlibBaseError as exc: 185 | raise AuthenticationFailed(str(exc), provider=self) 186 | 187 | 188 | class AuthlibIdentityProvider(IdentityProvider): 189 | """Provides identity information using Authlib. 190 | 191 | This provides access to all data returned by userinfo endpoint or id token. 192 | The type name to instantiate this provider is ``authlib``. 193 | """ 194 | 195 | #: If the provider supports refreshing identity information 196 | supports_refresh = False 197 | #: If the provider supports getting identity information based from 198 | #: an identifier 199 | supports_get = False 200 | 201 | def __init__(self, *args, **kwargs): 202 | super().__init__(*args, **kwargs) 203 | self.id_field = self.settings.setdefault('identifier_field', 'sub').lower() 204 | 205 | def get_identity_from_auth(self, auth_info): 206 | identifier = auth_info.data.get(self.id_field) 207 | if not identifier: 208 | raise IdentityRetrievalFailed(f'Identifier ({self.id_field}) missing in authlib response', 209 | details=auth_info.data, provider=self) 210 | return IdentityInfo(self, identifier=identifier, **auth_info.data) 211 | -------------------------------------------------------------------------------- /flask_multipass/providers/ldap/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | from flask_multipass.providers.ldap.providers import ( 8 | AuthFallbackLDAPIdentityProvider, 9 | LDAPAuthProvider, 10 | LDAPGroup, 11 | LDAPIdentityProvider, 12 | ) 13 | 14 | __all__ = ('LDAPAuthProvider', 'LDAPGroup', 'LDAPIdentityProvider', 'AuthFallbackLDAPIdentityProvider') 15 | -------------------------------------------------------------------------------- /flask_multipass/providers/ldap/exceptions.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | from flask_multipass.exceptions import MultipassException 8 | 9 | 10 | class LDAPException(MultipassException): 11 | """Base class for Multipass LDAP exceptions.""" 12 | 13 | 14 | class LDAPServerError(LDAPException): 15 | """Indicates the LDAP server had an unexpected behavior.""" 16 | -------------------------------------------------------------------------------- /flask_multipass/providers/ldap/globals.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | from werkzeug.local import LocalProxy, LocalStack 8 | 9 | _ldap_ctx_stack = LocalStack() 10 | 11 | #: Proxy to the current ldap connection and settings 12 | current_ldap = LocalProxy(lambda: _ldap_ctx_stack.top) 13 | -------------------------------------------------------------------------------- /flask_multipass/providers/ldap/operations.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | from ldap import NO_SUCH_OBJECT, SCOPE_BASE, SCOPE_SUBTREE 8 | from ldap.controls import SimplePagedResultsControl 9 | 10 | from flask_multipass.exceptions import GroupRetrievalFailed, IdentityRetrievalFailed 11 | from flask_multipass.providers.ldap.globals import current_ldap 12 | from flask_multipass.providers.ldap.util import build_search_filter, find_one, get_page_cookie 13 | 14 | 15 | def build_user_search_filter(criteria, mapping=None, exact=False): # pragma: no cover 16 | """Builds the LDAP search filter for retrieving users. 17 | 18 | :param criteria: dict -- Criteria to be `AND`ed together to build 19 | the filter. 20 | :param mapping: dict -- Mapping from criteria to LDAP attributes 21 | :param exact: bool -- Match attributes values exactly if ``True``, 22 | othewise perform substring matching. 23 | :return: str -- Valid LDAP search filter. 24 | """ 25 | type_filter = current_ldap.settings['user_filter'] 26 | return build_search_filter(criteria, type_filter, mapping, exact) 27 | 28 | 29 | def build_group_search_filter(criteria, mapping=None, exact=False): # pragma: no cover 30 | """Builds the LDAP search filter for retrieving groups. 31 | 32 | :param criteria: dict -- Criteria to be `AND`ed together to build 33 | the filter. 34 | :param mapping: dict -- Mapping from criteria to LDAP attributes 35 | :param exact: bool -- Match attributes values exactly if ``True``, 36 | othewise perform substring matching. 37 | :return: str -- Valid LDAP search filter. 38 | """ 39 | type_filter = current_ldap.settings['group_filter'] 40 | return build_search_filter(criteria, type_filter, mapping, exact) 41 | 42 | 43 | def get_user_by_id(uid, attributes=None): 44 | """Retrieves a user's data from LDAP, given its identifier. 45 | 46 | :param uid: str -- the identifier of the user 47 | :param attributes: list -- Attributes to be retrieved for the user. 48 | If ``None``, all attributes will be retrieved. 49 | :raises IdentityRetrievalFailed: If the identifier is falsely. 50 | :return: A tuple containing the `dn` of the user as ``str`` and the 51 | found attributes in a ``dict``. 52 | """ 53 | if not uid: 54 | raise IdentityRetrievalFailed('No identifier specified') 55 | user_filter = build_user_search_filter({current_ldap.settings['uid']: {uid}}, exact=True) 56 | return find_one(current_ldap.settings['user_base'], user_filter, attributes=attributes) 57 | 58 | 59 | def get_group_by_id(gid, attributes=None): 60 | """Retrieves a user's data from LDAP, given its identifier. 61 | 62 | :param gid: str -- the identifier of the group 63 | :param attributes: list -- Attributes to be retrieved for the group. 64 | If ``None``, all attributes will be retrieved. 65 | :raises GroupRetrievalFailed: If the identifier is falsely. 66 | :return: A tuple containing the `dn` of the group as ``str`` and the 67 | found attributes in a ``dict``. 68 | """ 69 | if not gid: 70 | raise GroupRetrievalFailed('No identifier specified') 71 | group_filter = build_group_search_filter({current_ldap.settings['gid']: {gid}}, exact=True) 72 | return find_one(current_ldap.settings['group_base'], group_filter, attributes=attributes) 73 | 74 | 75 | def search(base_dn, search_filter, attributes): 76 | """Iterative LDAP search using page control. 77 | 78 | :param base_dn: str -- The base DN from which to start the search. 79 | :param search_filter: str -- Representation of the filter to apply 80 | in the search. 81 | :param attributes: list -- Attributes to be retrieved for each 82 | entry. If ``None``, all attributes will be 83 | retrieved. 84 | :returns: A generator which yields one search result at a time as a 85 | tuple containing a `dn` as ``str`` and `attributes` as 86 | ``dict``. 87 | """ 88 | connection, settings = current_ldap 89 | page_ctrl = SimplePagedResultsControl(True, size=settings['page_size'], cookie='') 90 | 91 | while True: 92 | msg_id = connection.search_ext(base_dn, SCOPE_SUBTREE, filterstr=search_filter, attrlist=attributes, 93 | serverctrls=[page_ctrl], timeout=settings['timeout']) 94 | try: 95 | _, r_data, __, server_ctrls = connection.result3(msg_id, timeout=settings['timeout']) 96 | except NO_SUCH_OBJECT: 97 | break 98 | 99 | for dn, entry in r_data: 100 | if dn: 101 | yield dn, entry 102 | 103 | page_ctrl.cookie = get_page_cookie(server_ctrls) 104 | if not page_ctrl.cookie: 105 | # End of results 106 | break 107 | 108 | 109 | def get_token_groups_from_user_dn(user_dn): 110 | """Get the list of SIDs of nested groups the user is a member of. 111 | 112 | This is uses the Active Directory specific attribute `tokenGroups`, 113 | which is a list of security identifiers (SIDs) of groups (direct and 114 | nested) a user is a member of. This avoid a recursive lookup through 115 | the group memberships. 116 | To retrieve this attribute, a query on the user's DN using the base 117 | scope is required, hence the existence of this method instead of 118 | simply retrieving the attribute when looking for the user. 119 | 120 | :param user_dn: str -- DN of the user whose token groups list is 121 | retrieved 122 | :returns: list -- the secure identifiers of groups the user is a 123 | member of. 124 | """ 125 | entry = current_ldap.connection.search_ext_s(user_dn, SCOPE_BASE, attrlist=['tokenGroups'], 126 | timeout=current_ldap.settings['timeout'], sizelimit=1) 127 | user_data = next((data for dn, data in entry if dn), None) 128 | if not user_data: 129 | return [] 130 | return user_data.get('tokenGroups', []) 131 | -------------------------------------------------------------------------------- /flask_multipass/providers/ldap/providers.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | from warnings import warn 8 | 9 | from flask_wtf import FlaskForm 10 | from ldap import INVALID_CREDENTIALS 11 | from wtforms.fields import PasswordField, StringField 12 | from wtforms.validators import DataRequired 13 | 14 | from flask_multipass.auth import AuthProvider 15 | from flask_multipass.data import AuthInfo, IdentityInfo 16 | from flask_multipass.exceptions import GroupRetrievalFailed, IdentityRetrievalFailed, InvalidCredentials, NoSuchUser 17 | from flask_multipass.group import Group 18 | from flask_multipass.identity import IdentityProvider 19 | from flask_multipass.providers.ldap.globals import current_ldap 20 | from flask_multipass.providers.ldap.operations import ( 21 | build_group_search_filter, 22 | build_user_search_filter, 23 | get_group_by_id, 24 | get_token_groups_from_user_dn, 25 | get_user_by_id, 26 | search, 27 | ) 28 | from flask_multipass.providers.ldap.util import ldap_context, to_unicode 29 | from flask_multipass.util import convert_app_data 30 | 31 | try: 32 | import certifi 33 | except ImportError: 34 | certifi = None 35 | 36 | 37 | class LoginForm(FlaskForm): 38 | username = StringField('Username', [DataRequired()]) 39 | password = PasswordField('Password', [DataRequired()]) 40 | 41 | 42 | class LDAPProviderMixin: 43 | @property 44 | def ldap_settings(self): 45 | return self.settings['ldap'] 46 | 47 | def set_defaults(self): 48 | self.ldap_settings.setdefault('timeout', 30) 49 | self.ldap_settings.setdefault('verify_cert', True) 50 | self.ldap_settings.setdefault('cert_file', certifi.where() if certifi else None) 51 | self.ldap_settings.setdefault('starttls', False) 52 | self.ldap_settings.setdefault('page_size', 1000) 53 | self.ldap_settings.setdefault('uid', 'uid') 54 | self.ldap_settings.setdefault('user_filter', '(objectClass=person)') 55 | if not self.ldap_settings['cert_file'] and self.ldap_settings['verify_cert']: 56 | warn('You should install certifi or provide a certificate file in order to verify the LDAP certificate.', 57 | stacklevel=1) 58 | # Convert LDAP settings to text in case someone gave us bytes 59 | self.settings['ldap'] = to_unicode(self.settings['ldap']) 60 | 61 | 62 | class LDAPAuthProvider(LDAPProviderMixin, AuthProvider): 63 | """Provides authentication using LDAP. 64 | 65 | The type name to instantiate this provider is *ldap*. 66 | """ 67 | 68 | login_form = LoginForm 69 | 70 | def __init__(self, *args, **kwargs): 71 | super().__init__(*args, **kwargs) 72 | self.set_defaults() 73 | 74 | def process_local_login(self, data): 75 | username = data['username'] 76 | password = data['password'] 77 | with ldap_context(self.ldap_settings, use_cache=False): 78 | try: 79 | user_dn, user_data = get_user_by_id(username, attributes=[self.ldap_settings['uid']]) 80 | if not user_dn: 81 | raise NoSuchUser(provider=self, identifier=data['username']) 82 | current_ldap.connection.simple_bind_s(user_dn, password) 83 | except INVALID_CREDENTIALS: 84 | raise InvalidCredentials(provider=self, identifier=data['username']) 85 | auth_info = AuthInfo(self, identifier=user_data[self.ldap_settings['uid']][0]) 86 | return self.multipass.handle_auth_success(auth_info) 87 | 88 | 89 | class LDAPGroup(Group): 90 | """A group from the LDAP identity provider.""" 91 | 92 | #: If it is possible to get the list of members of a group. 93 | supports_member_list = True 94 | 95 | def __init__(self, provider, name, dn): # pragma: no cover 96 | super().__init__(provider, name) 97 | self.dn = dn 98 | 99 | @property 100 | def ldap_settings(self): # pragma: no cover 101 | return self.provider.ldap_settings 102 | 103 | @property 104 | def settings(self): # pragma: no cover 105 | return self.provider.settings 106 | 107 | def _iter_group(self): 108 | to_visit = {self.dn} 109 | visited = set() 110 | while to_visit: 111 | next_group_dn = to_visit.pop() 112 | visited.add(next_group_dn) 113 | groups = yield next_group_dn 114 | if groups: 115 | to_visit.update({group_dn for group_dn, group_data in groups if group_dn not in visited}) 116 | 117 | def get_members(self): 118 | with ldap_context(self.ldap_settings): 119 | group_dns = self._iter_group() 120 | group_dn = next(group_dns) 121 | while group_dn: 122 | user_filter = build_user_search_filter({self.ldap_settings['member_of_attr']: {group_dn}}, exact=True) 123 | for _, user_data in self.provider._search_users(user_filter): 124 | user_data = to_unicode(user_data) 125 | try: 126 | identifier = user_data[self.ldap_settings['uid']][0] 127 | except KeyError: 128 | # user does not have an identifier -> skip it 129 | continue 130 | yield IdentityInfo(self.provider, identifier=identifier, **user_data) 131 | group_filter = build_group_search_filter({self.ldap_settings['member_of_attr']: {group_dn}}, exact=True) 132 | subgroups = list(self.provider._search_groups(group_filter)) 133 | try: 134 | group_dn = group_dns.send(subgroups) 135 | except StopIteration: 136 | break 137 | 138 | def has_member(self, user_identifier): 139 | with ldap_context(self.ldap_settings): 140 | user_dn, user_data = get_user_by_id(user_identifier, attributes=[self.ldap_settings['member_of_attr']]) 141 | if not user_dn: 142 | return False 143 | if self.ldap_settings['ad_group_style']: 144 | _group_dn, group_data = get_group_by_id(self.name, attributes=['objectSid']) 145 | group_sids = group_data.get('objectSid', []) 146 | token_groups = get_token_groups_from_user_dn(user_dn) 147 | return any(group_sid in token_groups for group_sid in group_sids) 148 | else: 149 | user_data = to_unicode(user_data) 150 | return self.dn in user_data.get(self.ldap_settings['member_of_attr'], []) 151 | 152 | 153 | class LDAPIdentityProvider(LDAPProviderMixin, IdentityProvider): 154 | """Provides identity information using LDAP.""" 155 | 156 | #: If the provider supports refreshing user information 157 | supports_refresh = True 158 | #: If the provider supports searching users 159 | supports_search = True 160 | #: If the provider also provides groups and membership information 161 | supports_groups = True 162 | #: The class that represents groups from this provider 163 | group_class = LDAPGroup 164 | 165 | def __init__(self, *args, **kwargs): 166 | super().__init__(*args, **kwargs) 167 | self.set_defaults() 168 | self.ldap_settings.setdefault('gid', 'cn') 169 | self.ldap_settings.setdefault('group_filter', '(objectClass=groupOfNames)') 170 | self.ldap_settings.setdefault('member_of_attr', 'memberOf') 171 | self.ldap_settings.setdefault('ad_group_style', False) 172 | self.settings['mapping'] = to_unicode(self.settings['mapping']) 173 | self._attributes = list( 174 | convert_app_data(self.settings['mapping'], {}, self.settings['identity_info_keys']).values()) 175 | self._attributes.append(self.ldap_settings['uid']) 176 | 177 | @property 178 | def supports_get_identity_groups(self): 179 | return self.ldap_settings['ad_group_style'] 180 | 181 | def _get_identity(self, identifier): 182 | with ldap_context(self.ldap_settings): 183 | user_dn, user_data = get_user_by_id(identifier, self._attributes) 184 | if not user_dn: 185 | return None 186 | user_data = to_unicode(user_data) 187 | return IdentityInfo(self, identifier=user_data[self.ldap_settings['uid']][0], **user_data) 188 | 189 | def _search_users(self, search_filter): # pragma: no cover 190 | return search(self.ldap_settings['user_base'], search_filter, self._attributes) 191 | 192 | def _search_groups(self, search_filter): # pragma: no cover 193 | return search(self.ldap_settings['group_base'], search_filter, attributes=[self.ldap_settings['gid']]) 194 | 195 | def get_identity_from_auth(self, auth_info): # pragma: no cover 196 | return self._get_identity(auth_info.data.pop('identifier')) 197 | 198 | def refresh_identity(self, identifier, multipass_data): # pragma: no cover 199 | return self._get_identity(identifier) 200 | 201 | def get_identity(self, identifier): # pragma: no cover 202 | return self._get_identity(identifier) 203 | 204 | def search_identities(self, criteria, exact=False): 205 | with ldap_context(self.ldap_settings): 206 | search_filter = build_user_search_filter(criteria, self.settings['mapping'], exact=exact) 207 | if not search_filter: 208 | raise IdentityRetrievalFailed('Unable to generate search filter from criteria', provider=self) 209 | for _, user_data in self._search_users(search_filter): 210 | user_data = to_unicode(user_data) 211 | try: 212 | identifier = user_data[self.ldap_settings['uid']][0] 213 | except KeyError: 214 | # user does not have an identifier -> skip it 215 | continue 216 | yield IdentityInfo(self, identifier=identifier, **user_data) 217 | 218 | def get_identity_groups(self, identifier): 219 | groups = set() 220 | with ldap_context(self.ldap_settings): 221 | user_dn, _user_data = get_user_by_id(identifier, self._attributes) 222 | if not user_dn: 223 | return set() 224 | if self.ldap_settings['ad_group_style']: 225 | for sid in get_token_groups_from_user_dn(user_dn): 226 | search_filter = build_group_search_filter({'objectSid': {sid}}, exact=True) 227 | for group_dn, group_data in self._search_groups(search_filter): 228 | group_name = to_unicode(group_data[self.ldap_settings['gid']][0]) 229 | groups.add(self.group_class(self, group_name, group_dn)) 230 | else: 231 | # OpenLDAP does not have a way to get all groups for a user including nested ones 232 | raise NotImplementedError('Only available for active directory') 233 | return groups 234 | 235 | def get_group(self, name): 236 | with ldap_context(self.ldap_settings): 237 | group_dn, group_data = get_group_by_id(name, [self.ldap_settings['gid']]) 238 | if not group_dn: 239 | return None 240 | group_name = to_unicode(group_data[self.ldap_settings['gid']][0]) 241 | return self.group_class(self, group_name, group_dn) 242 | 243 | def search_groups(self, name, exact=False): 244 | with ldap_context(self.ldap_settings): 245 | search_filter = build_group_search_filter({self.ldap_settings['gid']: {name}}, exact=exact) 246 | if not search_filter: 247 | raise GroupRetrievalFailed('Unable to generate search filter from criteria', provider=self) 248 | for group_dn, group_data in self._search_groups(search_filter): 249 | group_name = to_unicode(group_data[self.ldap_settings['gid']][0]) 250 | yield self.group_class(self, group_name, group_dn) 251 | 252 | 253 | class AuthFallbackLDAPIdentityProvider(LDAPIdentityProvider): 254 | """Provides identity information using LDAP with a fallback to auth provider data. 255 | 256 | This identity provider is meant to be used together with an auth provider that provides 257 | all the required data (in particular the Shibboleth provider). 258 | 259 | By default it will use only the identifier from the auth provider and look up all the data 260 | from LDAP. 261 | 262 | In case the user does not have data in LDAP however, the data provided from the auth provider 263 | will be used. 264 | """ 265 | 266 | def get_identity_from_auth(self, auth_info): 267 | identifier = auth_info.data.get('identifier') 268 | if identity := super().get_identity_from_auth(auth_info): 269 | return identity 270 | return IdentityInfo(self, identifier=identifier, **auth_info.data) 271 | -------------------------------------------------------------------------------- /flask_multipass/providers/ldap/util.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | from collections import namedtuple 8 | from contextlib import contextmanager 9 | from urllib.parse import urlsplit 10 | from warnings import warn 11 | 12 | import ldap 13 | from flask import appcontext_tearing_down, current_app, g, has_app_context 14 | from ldap.controls import SimplePagedResultsControl 15 | from ldap.filter import escape_filter_chars 16 | from ldap.ldapobject import ReconnectLDAPObject 17 | 18 | from flask_multipass.exceptions import MultipassException 19 | from flask_multipass.providers.ldap.exceptions import LDAPServerError 20 | from flask_multipass.providers.ldap.globals import _ldap_ctx_stack, current_ldap 21 | from flask_multipass.util import convert_app_data 22 | 23 | #: A context holding the LDAP connection and the LDAP provider settings. 24 | LDAPContext = namedtuple('LDAPContext', ('connection', 'settings')) 25 | 26 | conn_keys = {'uri', 'bind_dn', 'bind_password', 'tls', 'starttls'} 27 | 28 | 29 | @appcontext_tearing_down.connect 30 | def _clear_ldap_cache(*args, **kwargs): 31 | if not has_app_context() or '_multipass_ldap_connections' not in g: 32 | return 33 | for conn in g._multipass_ldap_connections.values(): 34 | try: 35 | conn.unbind_s() 36 | except ldap.LDAPError: 37 | # That's ugly but we couldn't care less about a failure while disconnecting 38 | pass 39 | del g._multipass_ldap_connections 40 | 41 | 42 | def _get_ldap_cache(): 43 | """Returns the cache dictionary for ldap contexts.""" 44 | if not has_app_context(): 45 | return {} 46 | try: 47 | return g._multipass_ldap_connections 48 | except AttributeError: 49 | g._multipass_ldap_connections = cache = {} 50 | return cache 51 | 52 | 53 | @contextmanager 54 | def ldap_context(settings, use_cache=True): 55 | """Establishes an LDAP session context. 56 | 57 | Establishes a connection to the LDAP server from the `uri` in the 58 | ``settings`` and makes the context available in ``current_ldap``. 59 | 60 | Yields a namedtuple containing the connection to the server and the 61 | provider settings. 62 | 63 | :param settings: dict -- The settings for a LDAP provider. 64 | :param use_cache: bool -- If the connection should be cached. 65 | """ 66 | try: 67 | connection = ldap_connect(settings, use_cache=use_cache) 68 | ldap_ctx = LDAPContext(connection=connection, settings=settings) 69 | _ldap_ctx_stack.push(ldap_ctx) 70 | try: 71 | yield ldap_ctx 72 | except ldap.LDAPError: 73 | # If something went wrong we get rid of cached connections. 74 | # This is mostly for the python shell where you have a very 75 | # long-living application context that usually results in 76 | # the ldap connection timing out. 77 | _clear_ldap_cache() 78 | raise 79 | finally: 80 | assert _ldap_ctx_stack.pop() is ldap_ctx, 'Popped wrong LDAP context' 81 | except ldap.SERVER_DOWN: 82 | if has_app_context() and current_app.debug: 83 | raise 84 | raise MultipassException('The LDAP server is unreachable') 85 | except ldap.INVALID_CREDENTIALS: 86 | if has_app_context() and current_app.debug: 87 | raise 88 | raise ValueError('Invalid bind credentials') 89 | except ldap.SIZELIMIT_EXCEEDED: 90 | raise MultipassException('Size limit exceeded (try setting a smaller page size)') 91 | except ldap.TIMELIMIT_EXCEEDED: 92 | raise MultipassException('The time limit for the operation has been exceeded.') 93 | except ldap.TIMEOUT: 94 | raise MultipassException('The operation timed out.') 95 | except ldap.FILTER_ERROR: 96 | raise ValueError('The filter supplied to the operation is invalid. ' 97 | '(This is most likely due to a bad user or group filter.') 98 | 99 | 100 | def ldap_connect(settings, use_cache=True): 101 | """Establishes an LDAP connection. 102 | 103 | Establishes a connection to the LDAP server from the `uri` in the 104 | ``settings``. 105 | 106 | To establish a connection, the settings must be specified: 107 | - ``uri``: valid URI which points to a LDAP server, 108 | - ``bind_dn``: `dn` used to initially bind every LDAP connection 109 | - ``bind_password``" password used for the initial bind 110 | - ``tls``: ``True`` if the connection should use TLS encryption 111 | - ``starttls``: ``True`` to negotiate TLS with the server 112 | 113 | `Note`: ``starttls`` is ignored if the URI uses LDAPS and ``tls`` is 114 | set to ``True``. 115 | 116 | This function re-uses an existing LDAP connection if there is one 117 | available in the application context, unless caching is disabled. 118 | 119 | :param settings: dict -- The settings for a LDAP provider. 120 | :param use_cache: bool -- If the connection should be cached. 121 | :return: The ldap connection. 122 | """ 123 | if use_cache: 124 | cache = _get_ldap_cache() 125 | cache_key = frozenset((k, hash(v)) for k, v in settings.items() if k in conn_keys) 126 | conn = cache.get(cache_key) 127 | if conn is not None: 128 | return conn 129 | 130 | uri_info = urlsplit(settings['uri']) 131 | use_ldaps = uri_info.scheme == 'ldaps' 132 | credentials = (settings['bind_dn'], settings['bind_password']) 133 | ldap_connection = ReconnectLDAPObject(settings['uri'], bytes_mode=False) 134 | ldap_connection.protocol_version = ldap.VERSION3 135 | ldap_connection.set_option(ldap.OPT_REFERRALS, 0) 136 | if settings['verify_cert'] and settings['cert_file']: 137 | ldap_connection.set_option(ldap.OPT_X_TLS_CACERTFILE, settings['cert_file']) 138 | ldap_connection.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, 139 | ldap.OPT_X_TLS_DEMAND if settings['verify_cert'] else ldap.OPT_X_TLS_ALLOW) 140 | # force the creation of a new TLS context. This must be the last TLS option. 141 | # see: http://stackoverflow.com/a/27713355/298479 142 | ldap_connection.set_option(ldap.OPT_X_TLS_NEWCTX, 0) 143 | if use_ldaps and settings['starttls']: 144 | warn('Unable to start TLS, LDAP connection already secured over SSL (LDAPS)', stacklevel=1) 145 | elif settings['starttls']: 146 | ldap_connection.start_tls_s() 147 | # TODO: allow anonymous bind 148 | ldap_connection.simple_bind_s(*credentials) 149 | if use_cache: 150 | cache[cache_key] = ldap_connection 151 | return ldap_connection 152 | 153 | 154 | def find_one(base_dn, search_filter, attributes=None): 155 | """Looks for a single entry in the LDAP server. 156 | 157 | This will return the first entry given by the server which matches 158 | the ``search_filter`` found in the ``base_dn`` sub tree. If the 159 | ``search_filter`` matches multiples entries there is no guarantee 160 | the same entry is returned. 161 | 162 | :param base_dn: str -- The base DN from which to start the search. 163 | :param search_filter: str -- Representation of the filter to locate 164 | the entry. 165 | :param attributes: list -- Attributes to be retrieved for the entry. 166 | If ``None``, all attributes will be retrieved. 167 | :return: A tuple containing the `dn` of the entry as ``str`` and the 168 | found attributes in a ``dict``. 169 | """ 170 | entry = current_ldap.connection.search_ext_s(base_dn, ldap.SCOPE_SUBTREE, 171 | attrlist=attributes, filterstr=search_filter, 172 | timeout=current_ldap.settings['timeout'], sizelimit=1) 173 | return next(((dn, data) for dn, data in entry if dn), (None, None)) 174 | 175 | 176 | def _build_assert_template(value, exact): 177 | assert_template = '(%s=%s)' if exact else '(%s=*%s*)' 178 | if len(value) == 1: 179 | return assert_template 180 | else: 181 | return f'(|{assert_template * len(value)})' 182 | 183 | 184 | def _escape_filter_chars(value): 185 | if isinstance(value, str): 186 | return escape_filter_chars(value) 187 | else: 188 | return ''.join(fr'\{c:02x}' for c in value) 189 | 190 | 191 | def _filter_format(filter_template, assertion_values): 192 | # like python-ldap's filter_format, but handles binary data (bytes) gracefully by escaping 193 | # everything so things don't break when searching e.g. for someone's binary objectSid 194 | return filter_template % tuple(_escape_filter_chars(v) for v in assertion_values) 195 | 196 | 197 | def build_search_filter(criteria, type_filter, mapping=None, exact=False): 198 | """Builds a valid LDAP search filter for retrieving entries. 199 | 200 | :param criteria: dict -- Criteria to be ANDed together to build the 201 | filter, if a criterion has many values they will 202 | be ORed together. 203 | :param mapping: dict -- Mapping from criteria to LDAP attributes 204 | :param exact: bool -- Match attributes values exactly if ``True``, 205 | othewise perform substring matching. 206 | :return: str -- Valid LDAP search filter. 207 | """ 208 | assertions = convert_app_data(criteria, mapping or {}) 209 | assert_templates = [_build_assert_template(value, exact) for _, value in assertions.items()] 210 | assertions = [(k, v) for k, values in assertions.items() if k and values for v in values] 211 | if not assertions: 212 | return None 213 | filter_template = '(&{}{})'.format(''.join(assert_templates), type_filter) 214 | return _filter_format(filter_template, (item for assertion in assertions for item in assertion)) 215 | 216 | 217 | def get_page_cookie(server_ctrls): 218 | """Get the page control cookie from the server control list. 219 | 220 | :param server_ctrls: list -- Server controls including page control. 221 | :return: Cookie for page control or ``None`` if last page reached. 222 | :raises LDAPServerError: If the server doesn't support paging of 223 | search results. 224 | """ 225 | page_ctrls = [ctrl for ctrl in server_ctrls if ctrl.controlType == SimplePagedResultsControl.controlType] 226 | if not page_ctrls: 227 | raise LDAPServerError('The LDAP server ignores the RFC 2696 specification') 228 | return page_ctrls[0].cookie 229 | 230 | 231 | def to_unicode(data): 232 | if isinstance(data, bytes): 233 | return data.decode('utf-8', 'replace') 234 | elif isinstance(data, dict): 235 | return {to_unicode(k): to_unicode(v) for k, v in data.items()} 236 | elif isinstance(data, list): 237 | return [to_unicode(x) for x in data] 238 | elif isinstance(data, set): 239 | return {to_unicode(x) for x in data} 240 | elif isinstance(data, tuple): 241 | return tuple(to_unicode(x) for x in data) 242 | else: 243 | return data 244 | -------------------------------------------------------------------------------- /flask_multipass/providers/saml.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | from urllib.parse import urlsplit 8 | 9 | from flask import current_app, make_response, redirect, request, session, url_for 10 | from onelogin.saml2.auth import OneLogin_Saml2_Auth 11 | 12 | from flask_multipass.auth import AuthProvider 13 | from flask_multipass.data import AuthInfo, IdentityInfo 14 | from flask_multipass.exceptions import AuthenticationFailed, IdentityRetrievalFailed, MultipassException 15 | from flask_multipass.identity import IdentityProvider 16 | from flask_multipass.util import login_view 17 | 18 | 19 | class SAMLAuthProvider(AuthProvider): 20 | """Provides authentication using SAML. 21 | 22 | The type name to instantiate this provider is *saml*. 23 | """ 24 | 25 | def __init__(self, *args, **kwargs): 26 | super().__init__(*args, **kwargs) 27 | self.strip_prefix = self.settings.setdefault('strip_prefix', 'http://schemas.xmlsoap.org/claims/') 28 | self.saml_acs_uri = self.settings.setdefault('saml_acs_uri', f'/multipass/saml/{self.name}/acs') 29 | self.saml_sls_uri = self.settings.setdefault('saml_sls_uri', f'/multipass/saml/{self.name}/sls') 30 | self.saml_metadata_uri = self.settings.setdefault('saml_metadata_uri', f'/multipass/saml/{self.name}/metadata') 31 | self.saml_acs_endpoint = f'_flaskmultipass_saml_acs_{self.name}' 32 | self.saml_sls_endpoint = f'_flaskmultipass_saml_sls_{self.name}' 33 | self.saml_metadata_endpoint = f'_flaskmultipass_saml_metadata_{self.name}' 34 | self.use_friendly_names = self.settings.get('saml_friendly_names', False) 35 | current_app.add_url_rule(self.saml_acs_uri, self.saml_acs_endpoint, self._saml_acs, methods=('GET', 'POST')) 36 | current_app.add_url_rule(self.saml_sls_uri, self.saml_sls_endpoint, self._saml_sls, methods=('GET', 'POST')) 37 | current_app.add_url_rule(self.saml_metadata_uri, self.saml_metadata_endpoint, self._saml_metadata) 38 | 39 | @property 40 | def saml_config(self): 41 | return self.settings['saml_config'] 42 | 43 | def _prepare_flask_request(self): 44 | url_data = urlsplit(request.url) 45 | return { 46 | 'https': 'on' if request.scheme == 'https' else 'off', 47 | 'http_host': request.host, 48 | 'server_port': url_data.port, 49 | 'script_name': request.path, 50 | 'get_data': request.args.copy(), 51 | 'post_data': request.form.copy(), 52 | # enable when using ADFS as IdP, https://github.com/onelogin/python-saml/pull/144 53 | 'lowercase_urlencoding': self.settings.get('lowercase_urlencoding', False), 54 | } 55 | 56 | def _init_saml_auth(self): 57 | config = dict(self.saml_config) 58 | config.setdefault('strict', True) 59 | config.setdefault('debug', False) 60 | idp_config = config.setdefault('idp', {}) 61 | if not idp_config: 62 | # dummy data so we can generate the SP metadata 63 | idp_config.update({ 64 | 'entityId': 'https://idp.example.com', 65 | 'singleSignOnService': { 66 | 'url': 'https://idp.example.com/saml', 67 | 'binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', 68 | }, 69 | 'singleLogoutService': { 70 | 'url': 'https://idp.example.com/saml', 71 | 'binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', 72 | }, 73 | 'x509cert': '', 74 | }) 75 | config['sp'].setdefault('NameIDFormat', 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent') 76 | acs_config = config['sp'].setdefault('assertionConsumerService', {}) 77 | acs_config.setdefault('binding', 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST') 78 | acs_config['url'] = url_for(self.saml_acs_endpoint, _external=True) 79 | slo_config = config['sp'].get('singleLogoutService') 80 | if slo_config is not None: 81 | slo_config.setdefault('binding', 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect') 82 | slo_config['url'] = url_for(self.saml_sls_endpoint, _external=True) 83 | req = self._prepare_flask_request() 84 | return OneLogin_Saml2_Auth(req, config) 85 | 86 | def _make_session_key(self, name): 87 | return f'_flaskmultipass_saml_{self.name}_{name}' 88 | 89 | def initiate_external_login(self): 90 | auth = self._init_saml_auth() 91 | return redirect(auth.login()) 92 | 93 | def process_logout(self, return_url): 94 | auth = self._init_saml_auth() 95 | if auth.get_slo_url() is None: 96 | return None 97 | name_id = session_index = name_id_format = name_id_nq = name_id_spnq = None 98 | name_id = session.get(self._make_session_key('name_id'), None) 99 | name_id_format = session.get(self._make_session_key('name_id_format'), None) 100 | name_id_nq = session.get(self._make_session_key('name_id_nq'), None) 101 | name_id_spnq = session.get(self._make_session_key('name_id_spnq'), None) 102 | session_index = session.get(self._make_session_key('session_index'), None) 103 | return redirect(auth.logout(name_id=name_id, session_index=session_index, nq=name_id_nq, 104 | name_id_format=name_id_format, spnq=name_id_spnq, 105 | return_to=return_url)) 106 | 107 | @login_view 108 | def _saml_acs(self): 109 | auth = self._init_saml_auth() 110 | session_key_request_id = self._make_session_key('request_id') 111 | session_key_name_id = self._make_session_key('name_id') 112 | session_key_name_id_format = self._make_session_key('name_id_format') 113 | session_key_name_id_nq = self._make_session_key('name_id_nq') 114 | session_key_name_id_spnq = self._make_session_key('name_id_spnq') 115 | session_key_session_index = self._make_session_key('session_index') 116 | request_id = session.get(session_key_request_id) 117 | auth.process_response(request_id=request_id) 118 | errors = auth.get_errors() 119 | if errors: 120 | error_reason = 'SAML login failed' 121 | if auth.get_settings().is_debug_active(): 122 | error_reason += f' ({auth.get_last_error_reason()})' 123 | raise AuthenticationFailed(error_reason) 124 | session.pop(session_key_request_id, None) 125 | session[session_key_name_id] = saml_nameid = auth.get_nameid() 126 | session[session_key_name_id_format] = auth.get_nameid_format() 127 | session[session_key_name_id_nq] = saml_nameid_nq = auth.get_nameid_nq() 128 | session[session_key_name_id_spnq] = saml_nameid_spnq = auth.get_nameid_spnq() 129 | session[session_key_session_index] = auth.get_session_index() 130 | 131 | attributes = auth.get_friendlyname_attributes() if self.use_friendly_names else auth.get_attributes() 132 | if self.strip_prefix: 133 | attributes = {k.removeprefix(self.strip_prefix): v for k, v in attributes.items()} 134 | # flatten single-element lists; otherwise linking e.g. to an LDAP identity 135 | # provider is not possible 136 | attributes = {k: v[0] if isinstance(v, list) and len(v) == 1 else v for k, v in attributes.items()} 137 | attributes['_saml_nameid'] = saml_nameid 138 | attributes['_saml_nameid_qualified'] = '@'.join(x for x in [saml_nameid, saml_nameid_nq, saml_nameid_spnq] 139 | if x is not None) 140 | if auth.get_settings().is_debug_active(): 141 | print(f'Login successful; received attributes: {attributes}') 142 | if not attributes: 143 | raise AuthenticationFailed('No valid data received', provider=self) 144 | return self.multipass.handle_auth_success(AuthInfo(self, **attributes)) 145 | 146 | def _saml_sls(self): 147 | auth = self._init_saml_auth() 148 | session_key_logout_request_id = self._make_session_key('logout_request_id') 149 | request_id = session.get(session_key_logout_request_id) 150 | dscb = lambda: session.clear() 151 | url = auth.process_slo(request_id=request_id, delete_session_cb=dscb) 152 | errors = auth.get_errors() 153 | if errors: 154 | error_reason = 'SAML logout failed' 155 | if auth.get_settings().is_debug_active(): 156 | error_reason += f' ({auth.get_last_error_reason()})' 157 | raise MultipassException(error_reason) 158 | if url is not None: 159 | return redirect(url) 160 | else: 161 | return redirect(auth.redirect_to(request.form.get('RelayState', '/'))) 162 | 163 | def _saml_metadata(self): 164 | auth = self._init_saml_auth() 165 | settings = auth.get_settings() 166 | metadata = settings.get_sp_metadata() 167 | errors = settings.validate_metadata(metadata) 168 | 169 | if errors: 170 | return make_response(', '.join(errors), 500) 171 | resp = make_response(metadata, 200) 172 | resp.headers['Content-Type'] = 'text/xml' 173 | return resp 174 | 175 | 176 | class SAMLIdentityProvider(IdentityProvider): 177 | """Provides identity information using SAML. 178 | 179 | The type name to instantiate this provider is *saml*. 180 | """ 181 | 182 | #: If the provider supports getting identity information based from 183 | #: an identifier 184 | supports_get = False 185 | 186 | def __init__(self, *args, **kwargs): 187 | super().__init__(*args, **kwargs) 188 | self.id_field = self.settings.setdefault('identifier_field', '_saml_nameid_qualified') 189 | 190 | def get_identity_from_auth(self, auth_info): 191 | id_fields = [self.id_field] if isinstance(self.id_field, str) else self.id_field 192 | valid_identifier = None 193 | for key in id_fields: 194 | identifier = auth_info.data.get(key) 195 | if not identifier: 196 | continue 197 | if isinstance(identifier, list): 198 | if len(identifier) != 1: 199 | raise IdentityRetrievalFailed('Identifier has multiple elements', details=identifier, provider=self) 200 | valid_identifier = identifier[0] 201 | break 202 | else: 203 | valid_identifier = identifier 204 | break 205 | if not valid_identifier: 206 | raise IdentityRetrievalFailed('Identifier missing in saml response', details=auth_info.data, provider=self) 207 | return IdentityInfo(self, identifier=valid_identifier, **auth_info.data) 208 | -------------------------------------------------------------------------------- /flask_multipass/providers/shibboleth.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | from urllib.parse import quote 8 | 9 | from flask import current_app, redirect, request, url_for 10 | 11 | from flask_multipass.auth import AuthProvider 12 | from flask_multipass.data import AuthInfo, IdentityInfo 13 | from flask_multipass.exceptions import AuthenticationFailed, IdentityRetrievalFailed, MultipassException 14 | from flask_multipass.identity import IdentityProvider 15 | from flask_multipass.util import login_view 16 | 17 | 18 | def _lower_keys(iter_): 19 | for k, v in iter_: 20 | yield k.lower(), v 21 | 22 | 23 | class ShibbolethAuthProvider(AuthProvider): 24 | """Provides authentication using Shibboleth. 25 | 26 | This provider requires the application to run inside the Apache 27 | webserver with mod_shib. 28 | 29 | The type name to instantiate this provider is *shibboleth*. 30 | """ 31 | 32 | def __init__(self, *args, **kwargs): 33 | super().__init__(*args, **kwargs) 34 | # convert everything to lowercase (headers/WSGI vars are case-insensitive) 35 | self.attrs_prefix = self.settings.setdefault('attrs_prefix', 'ADFS_').lower() 36 | self.attrs = [attr.lower() for attr in self.settings.get('attrs', [])] or None 37 | if not self.settings.get('callback_uri'): 38 | raise MultipassException('`callback_uri` must be specified in the provider settings', provider=self) 39 | self.from_headers = self.settings.get('from_headers', False) 40 | self.shibboleth_endpoint = '_flaskmultipass_shibboleth_' + self.name 41 | current_app.add_url_rule(self.settings['callback_uri'], self.shibboleth_endpoint, 42 | self._shibboleth_callback, methods=('GET', 'POST')) 43 | 44 | def initiate_external_login(self): 45 | return redirect(url_for(self.shibboleth_endpoint)) 46 | 47 | def process_logout(self, return_url): 48 | logout_uri = self.settings.get('logout_uri') 49 | if logout_uri: 50 | return redirect(logout_uri.format(return_url=quote(return_url))) 51 | 52 | @login_view 53 | def _shibboleth_callback(self): 54 | data_source = request.headers if self.from_headers else request.environ 55 | mapping = _lower_keys(data_source.items()) 56 | # get all attrs in the 'attrs' list, if empty use 'attrs_prefix' 57 | if self.attrs is None: 58 | attributes = {k: v for k, v in mapping if k.startswith(self.attrs_prefix)} 59 | else: 60 | attributes = {k: v for k, v in mapping if k in self.attrs} 61 | 62 | if not attributes: 63 | raise AuthenticationFailed('No valid data received', provider=self) 64 | return self.multipass.handle_auth_success(AuthInfo(self, **attributes)) 65 | 66 | 67 | class ShibbolethIdentityProvider(IdentityProvider): 68 | """Provides identity information using Shibboleth. 69 | 70 | This provider requires the application to run inside the Apache 71 | webserver with mod_shib. 72 | 73 | The type name to instantiate this provider is *shibboleth*. 74 | """ 75 | 76 | #: If the provider supports getting identity information based from 77 | #: an identifier 78 | supports_get = False 79 | 80 | def __init__(self, *args, **kwargs): 81 | super().__init__(*args, **kwargs) 82 | # make headers/vars case-insensitive 83 | self.id_field = self.settings.setdefault('identifier_field', 'ADFS_LOGIN').lower() 84 | self.settings['mapping'] = {k: v.lower() for k, v in self.settings['mapping'].items()} 85 | 86 | def get_identity_from_auth(self, auth_info): 87 | identifier = auth_info.data.get(self.id_field) 88 | if not identifier: 89 | raise IdentityRetrievalFailed('Identifier missing in shibboleth response', 90 | details=auth_info.data, provider=self) 91 | return IdentityInfo(self, identifier=identifier, **auth_info.data) 92 | -------------------------------------------------------------------------------- /flask_multipass/providers/sqlalchemy.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | from flask_wtf import FlaskForm 8 | from sqlalchemy import inspect 9 | from wtforms.fields import PasswordField, StringField 10 | from wtforms.validators import DataRequired 11 | 12 | from flask_multipass import AuthInfo, AuthProvider, IdentityInfo, IdentityProvider, InvalidCredentials, NoSuchUser 13 | 14 | 15 | class LoginForm(FlaskForm): 16 | identifier = StringField('Username', [DataRequired()]) 17 | password = PasswordField('Password', [DataRequired()]) 18 | 19 | 20 | class SQLAlchemyAuthProviderBase(AuthProvider): 21 | """Provides authentication against passwords stored in SQLAlchemy. 22 | 23 | This provider expects your application to have an "identity" model 24 | which maps identifiers from IdentityInfo objects to users. For further 25 | details on how to use this provider, please see the example 26 | application. 27 | 28 | To use it, you have to subclass it in your application. 29 | """ 30 | 31 | #: The :class:`~flask_wtf.Form` that is used for the login dialog 32 | login_form = LoginForm 33 | #: The Flask-SQLAlchemy model representing a user identity 34 | identity_model = None 35 | #: The column of the identity model that contains the provider 36 | #: name. This needs to be a SQLAlchemy column object, e.g. 37 | #: ``Identity.provider`` 38 | provider_column = None 39 | #: The column of the identity model that contains the identifier, 40 | #: i.e. the username. This needs to be a SQLAlchemy column object, 41 | #: e.g. ``Identity.identifier`` 42 | identifier_column = None 43 | 44 | def check_password(self, identity, password): 45 | """Checks the entered password. 46 | 47 | :param identity: An instance of :attr:`identity_model`. 48 | :param password: The password entered by the user. 49 | """ 50 | raise NotImplementedError 51 | 52 | def process_local_login(self, data): 53 | identity = self.identity_model.query.filter(type(self).provider_column == self.name, 54 | type(self).identifier_column == data['identifier']).first() 55 | if not identity: 56 | raise NoSuchUser(provider=self, identifier=data['identifier']) 57 | if not self.check_password(identity, data['password']): 58 | raise InvalidCredentials(provider=self, identifier=data['identifier']) 59 | auth_info = AuthInfo(self, identity=identity) 60 | return self.multipass.handle_auth_success(auth_info) 61 | 62 | 63 | class SQLAlchemyIdentityProviderBase(IdentityProvider): 64 | """Provides identity information for users stored in SQLAlchemy. 65 | 66 | This provider expects your application to have an "identity" model 67 | which maps identifiers from IdentityInfo objects to users. For further 68 | details on how to use this provider, please see the example 69 | application. 70 | 71 | The provider returns all columns from the user model; use the 72 | configurable mapping to restrict the data returned. 73 | 74 | To use it, you have to subclass it in your application. 75 | """ 76 | 77 | #: The relationship of the identity model that points to the 78 | #: associated user object. This can be either a SQLAlchemy 79 | #: relationship object such as ``Identity.user`` or a string 80 | #: containing the attribute name of the relationship. 81 | identity_user_relationship = None 82 | #: The Flask-SQLAlchemy model representing a user. 83 | user_model = None 84 | #: Getting an identity based on the identifier does not make lots 85 | #: of sense for identities coming from the local database. 86 | supports_get = False 87 | 88 | def get_identity_from_auth(self, auth_info): 89 | cls = type(self) 90 | identity = auth_info.data['identity'] 91 | if isinstance(cls.identity_user_relationship, str): 92 | relationship_name = cls.identity_user_relationship 93 | else: 94 | relationship_name = cls.identity_user_relationship.key 95 | user = getattr(identity, relationship_name) 96 | # Get all columns from the user model 97 | mapper = inspect(self.user_model) 98 | data = {x.key: getattr(user, x.key) for x in mapper.attrs} 99 | return IdentityInfo(self, identity.identifier, **data) 100 | -------------------------------------------------------------------------------- /flask_multipass/providers/static.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | import itertools 8 | import operator 9 | 10 | from flask_wtf import FlaskForm 11 | from wtforms.fields import PasswordField, StringField 12 | from wtforms.validators import DataRequired 13 | 14 | from flask_multipass.auth import AuthProvider 15 | from flask_multipass.data import AuthInfo, IdentityInfo 16 | from flask_multipass.exceptions import InvalidCredentials, NoSuchUser 17 | from flask_multipass.group import Group 18 | from flask_multipass.identity import IdentityProvider 19 | 20 | 21 | class StaticLoginForm(FlaskForm): 22 | username = StringField('Username', [DataRequired()]) 23 | password = PasswordField('Password', [DataRequired()]) 24 | 25 | 26 | class StaticAuthProvider(AuthProvider): 27 | """Provides authentication against a static list. 28 | 29 | This provider should NEVER be use in any production system. 30 | It serves mainly as a simple dummy/example for development. 31 | 32 | The type name to instantiate this provider is *static*. 33 | """ 34 | 35 | login_form = StaticLoginForm 36 | 37 | def __init__(self, *args, **kwargs): 38 | super().__init__(*args, **kwargs) 39 | self.settings.setdefault('identities', {}) 40 | 41 | def process_local_login(self, data): 42 | username = data['username'] 43 | password = self.settings['identities'].get(username) 44 | if password is None: 45 | raise NoSuchUser(provider=self, identifier=data['identifier']) 46 | if password != data['password']: 47 | raise InvalidCredentials(provider=self, identifier=data['username']) 48 | auth_info = AuthInfo(self, username=data['username']) 49 | return self.multipass.handle_auth_success(auth_info) 50 | 51 | 52 | class StaticGroup(Group): 53 | """A group from the static identity provider.""" 54 | 55 | supports_member_list = True 56 | 57 | def get_members(self): 58 | members = self.provider.settings['groups'][self.name] 59 | for username in members: 60 | yield self.provider._get_identity(username) 61 | 62 | def has_member(self, identifier): 63 | return identifier in self.provider.settings['groups'][self.name] 64 | 65 | 66 | class StaticIdentityProvider(IdentityProvider): 67 | """Provides identity information from a static list. 68 | 69 | This provider should NEVER be use in any production system. 70 | It serves mainly as a simple dummy/example for development. 71 | 72 | The type name to instantiate this provider is *static*. 73 | """ 74 | 75 | #: If the provider supports refreshing user information 76 | supports_refresh = True 77 | #: If the provider supports searching identities 78 | supports_search = True 79 | #: If the provider also provides groups and membership information 80 | supports_groups = True 81 | #: If the provider supports getting the list of groups an identity belongs to 82 | supports_get_identity_groups = True 83 | #: The class that represents groups from this provider 84 | group_class = StaticGroup 85 | 86 | def __init__(self, *args, **kwargs): 87 | super().__init__(*args, **kwargs) 88 | self.settings.setdefault('identities', {}) 89 | self.settings.setdefault('groups', {}) 90 | 91 | def _get_identity(self, identifier): 92 | user = self.settings['identities'].get(identifier) 93 | if user is None: 94 | return None 95 | return IdentityInfo(self, identifier, **user) 96 | 97 | def get_identity_from_auth(self, auth_info): 98 | identifier = auth_info.data['username'] 99 | return self._get_identity(identifier) 100 | 101 | def refresh_identity(self, identifier, multipass_data): 102 | return self._get_identity(identifier) 103 | 104 | def get_identity(self, identifier): 105 | return self._get_identity(identifier) 106 | 107 | def search_identities(self, criteria, exact=False): 108 | for identifier, user in self.settings['identities'].items(): 109 | for key, values in criteria.items(): 110 | # same logic as multidict 111 | user_value = user.get(key) 112 | user_values = set(user_value) if isinstance(user_value, (tuple, list)) else {user_value} 113 | if not any(user_values): 114 | break 115 | elif exact and not user_values & set(values): 116 | break 117 | elif not exact and not any(sv in uv for sv, uv in itertools.product(values, user_values)): 118 | break 119 | else: 120 | yield IdentityInfo(self, identifier, **user) 121 | 122 | def get_identity_groups(self, identifier): 123 | groups = set() 124 | for group_name in self.settings['groups']: 125 | group = self.get_group(group_name) 126 | if identifier in group: 127 | groups.add(group) 128 | return groups 129 | 130 | def get_group(self, name): 131 | if name not in self.settings['groups']: 132 | return None 133 | return self.group_class(self, name) 134 | 135 | def search_groups(self, name, exact=False): 136 | compare = operator.eq if exact else operator.contains 137 | for group_name in self.settings['groups']: 138 | if compare(group_name, name): 139 | yield self.group_class(self, group_name) 140 | -------------------------------------------------------------------------------- /flask_multipass/util.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | import sys 8 | from functools import wraps 9 | from importlib.metadata import entry_points as importlib_entry_points 10 | from inspect import getmro, isclass 11 | 12 | from flask import current_app 13 | 14 | from flask_multipass.exceptions import MultipassException 15 | 16 | 17 | def convert_app_data(app_data, mapping, key_filter=None): 18 | """Converts data coming from the application to be used by the provider. 19 | 20 | :param app_data: dict -- Data coming from the application. 21 | :param mapping: dict -- Mapping between keys used to define the data 22 | in the application and those used by the provider. 23 | :param key_filter: list -- Keys to be exclusively considered. If 24 | ``None``, all items will be returned. 25 | :return: dict -- containing the values of `app_data` mapped to the 26 | keys of the provider as defined in the `mapping` and 27 | filtered out by `key_filter`. 28 | """ 29 | if key_filter: 30 | key_filter = set(key_filter) 31 | app_data = {k: v for k, v in app_data.items() if k in key_filter} 32 | return {mapping.get(key, key): value for key, value in app_data.items()} 33 | 34 | 35 | def convert_provider_data(provider_data, mapping, key_filter=None): 36 | """Converts data coming from the provider to be used by the application. 37 | 38 | The result will have all the keys listed in `keys` with values 39 | coming either from `data` (using the key mapping defined in 40 | `mapping`) or ``None`` in case the key is not present. If 41 | `key_filter` is ``None``, all keys from `provider_data` will be used 42 | unless they are mapped to a different key in `mapping`. 43 | 44 | :param provider_data: dict -- Data coming from the provider. 45 | :param mapping: dict -- Mapping between keys used to define the data 46 | in the provider and those used by the application. 47 | All application keys will be present in the return 48 | value, defaulting to ``None``. 49 | :param key_filter: list -- Keys to be exclusively considered. If 50 | ``None``, all items will be returned. Keys not 51 | present in the mapped data, will have a value of 52 | ``None``. 53 | :return: dict -- containing the values of `app_data` mapped to the 54 | keys of the application as defined in the `mapping` and 55 | filtered out by `key_filter`. 56 | """ 57 | provider_keys = set(mapping.values()) 58 | result = {key: value for key, value in provider_data.items() if key not in provider_keys} 59 | result.update((app_key, provider_data.get(provider_key)) for app_key, provider_key in mapping.items()) 60 | if key_filter is not None: 61 | key_filter = set(key_filter) 62 | result = {key: value for key, value in result.items() if key in key_filter} 63 | result.update(dict.fromkeys(key_filter - set(result))) 64 | return result 65 | 66 | 67 | def get_canonical_provider_map(provider_map): 68 | """Converts the configured provider map to a canonical form.""" 69 | canonical = {} 70 | for auth_provider_name, identity_providers in provider_map.items(): 71 | if not isinstance(identity_providers, (list, tuple, set)): 72 | identity_providers = [identity_providers] 73 | identity_providers = tuple({'identity_provider': p} if isinstance(p, str) else p for p in identity_providers) 74 | canonical[auth_provider_name] = identity_providers 75 | return canonical 76 | 77 | 78 | def get_state(app=None): 79 | """Gets the application-specific multipass data. 80 | 81 | :param app: The Flask application. Defaults to the current app. 82 | :rtype: flask_multipass.core._MultipassState 83 | """ 84 | if app is None: 85 | app = current_app 86 | assert 'multipass' in app.extensions, ( 87 | 'The multipass extension was not registered to the current application. ' 88 | 'Please make sure to call init_app() first.' 89 | ) 90 | return app.extensions['multipass'] 91 | 92 | 93 | def get_provider_base(cls): 94 | """Returns the base class of a provider class. 95 | 96 | :param cls: A subclass of either :class:`.AuthProvider` or 97 | :class:`.IdentityProvider`. 98 | :return: :class:`.AuthProvider` or :class:`.IdentityProvider` 99 | """ 100 | from flask_multipass.auth import AuthProvider 101 | from flask_multipass.identity import IdentityProvider 102 | if issubclass(cls, AuthProvider) and issubclass(cls, IdentityProvider): 103 | raise TypeError('Class inherits from both AuthProvider and IdentityProvider: ' + cls.__name__) 104 | elif issubclass(cls, AuthProvider): 105 | return AuthProvider 106 | elif issubclass(cls, IdentityProvider): 107 | return IdentityProvider 108 | else: 109 | raise TypeError('Class is neither an auth nor an identity provider: ' + cls.__name__) 110 | 111 | 112 | def login_view(func): 113 | """Decorates a Flask view function as an authentication view. 114 | 115 | This catches multipass-related exceptions and flashes a message and 116 | redirects back to the login page. 117 | """ 118 | @wraps(func) 119 | def decorator(*args, **kwargs): 120 | try: 121 | return func(*args, **kwargs) 122 | except MultipassException as e: 123 | return get_state().multipass.handle_auth_error(e, True) 124 | 125 | return decorator 126 | 127 | 128 | def resolve_provider_type(base, type_, registry=None): 129 | """Resolves a provider type to its class. 130 | 131 | :param base: The base class of the provider 132 | :param type_: The type of the provider. Can be a subclass of 133 | `base` or the identifier of a registered type. 134 | :param registry: A dict containing registered providers. This 135 | complements the entrypoint-based lookup. Any 136 | provider type defined in this dict takes priority 137 | over an entrypoint-based one with the same name. 138 | :return: The type's class, which is a subclass of `base`. 139 | """ 140 | if isclass(type_): 141 | if not issubclass(type_, base): 142 | raise TypeError(f'Received a class {type_} which is not a subclass of {base}') 143 | return type_ 144 | 145 | if registry is not None and type_ in registry: 146 | cls = registry[type_] 147 | else: 148 | if sys.version_info < (3, 10): 149 | entry_points = {ep for ep in importlib_entry_points().get(base._entry_point, []) if ep.name == type_} 150 | else: 151 | entry_points = importlib_entry_points(group=base._entry_point, name=type_) 152 | if not entry_points: 153 | raise ValueError('Unknown type: ' + type_) 154 | elif len(entry_points) != 1: 155 | defs = ', '.join(ep.module for ep in entry_points) 156 | raise RuntimeError(f'Type {type_} is not unique. Defined in {defs}') 157 | entry_point = list(entry_points)[0] 158 | cls = entry_point.load() 159 | if not issubclass(cls, base): 160 | raise TypeError(f'Found a class {cls} which is not a subclass of {base}') 161 | return cls 162 | 163 | 164 | def validate_provider_map(state): 165 | """Validates the provider map. 166 | 167 | :param state: The :class:`._MultipassState` instance 168 | """ 169 | invalid_keys = state.auth_providers.keys() - state.provider_map.keys() 170 | if invalid_keys: 171 | raise ValueError('Auth providers not linked to identity providers: ' + ', '.join(invalid_keys)) 172 | targeted_providers = {p['identity_provider'] for providers in state.provider_map.values() for p in providers} 173 | invalid_keys = targeted_providers - state.identity_providers.keys() 174 | if invalid_keys: 175 | raise ValueError('Broken identity provider links: ' + ', '.join(invalid_keys)) 176 | 177 | 178 | class classproperty(property): # noqa: N801 179 | """Like a :class:`property`, but for a class. 180 | 181 | Usage:: 182 | 183 | class Foo: 184 | @classproperty 185 | @classmethod 186 | def foo(cls): 187 | return 'bar' 188 | """ 189 | 190 | def __get__(self, obj, type=None): 191 | return self.fget.__get__(None, type)() 192 | 193 | 194 | class SupportsMeta(type): 195 | """ 196 | Metaclass that requires/prohibits methods to be overridden 197 | depending on class attributes. 198 | 199 | The class using this metaclass must have a `__support_attrs__` 200 | attribute containing a dict mapping attribute names to method 201 | names (or lists of method names) which must be overridden if the 202 | attribute is True and may not mbe overridden if it isn't. 203 | 204 | Instead of a string key the dict may also contain a tuple returned 205 | from :meth:`callable`. 206 | """ 207 | 208 | def __new__(mcs, name, bases, dct): 209 | cls = type.__new__(mcs, name, bases, dct) 210 | base = next((x for x in reversed(getmro(cls)) if type(x) is mcs and x is not cls), None) 211 | if base is None: 212 | return cls 213 | for attr, methods in base.__support_attrs__.items(): 214 | if isinstance(methods, str): 215 | methods = (methods,) 216 | if isinstance(attr, tuple): 217 | supported = attr[0](cls) 218 | message = attr[1] 219 | else: 220 | supported = getattr(cls, attr, getattr(base, attr)) 221 | message = f'{attr} is True' 222 | for method in methods: 223 | is_overridden = (getattr(base, method) != getattr(cls, method)) 224 | if not supported and is_overridden: 225 | raise TypeError(f'{name} cannot override {method} unless {message}') 226 | elif supported and not is_overridden: 227 | raise TypeError(f'{name} must override {method} if {message}') 228 | return cls 229 | 230 | @staticmethod 231 | def callable(func, message): 232 | """Returns an object suitable for more complex. 233 | 234 | :param func: A callable that is invoked with the dict of the 235 | newly created object 236 | :param message: The message to show in case of a failure. 237 | """ 238 | return func, message 239 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = 'Flask-Multipass' 3 | version = '0.10' 4 | description = 'A pluggable solution for multi-backend authentication with Flask' 5 | readme = 'README.rst' 6 | license = 'BSD-3-Clause' 7 | authors = [{ name = 'Indico Team', email = 'indico-team@cern.ch' }] 8 | classifiers = [ 9 | 'Environment :: Web Environment', 10 | 'Framework :: Flask', 11 | 'License :: OSI Approved :: BSD License', 12 | 'Programming Language :: Python :: 3.9', 13 | 'Programming Language :: Python :: 3.10', 14 | 'Programming Language :: Python :: 3.11', 15 | 'Programming Language :: Python :: 3.12', 16 | 'Programming Language :: Python :: 3.13', 17 | ] 18 | requires-python = '~=3.9' 19 | dependencies = ['flask', 'blinker'] 20 | 21 | [project.optional-dependencies] 22 | dev = ['pytest', 'pytest-cov', 'pytest-mock', 'ruff'] 23 | authlib = ['authlib>=0.14.1', 'requests'] 24 | ldap = ['flask-wtf', 'python-ldap>=3.3.1'] 25 | saml = ['python3-saml>=1.10.1'] 26 | sqlalchemy = ['sqlalchemy', 'flask-wtf'] 27 | 28 | [project.urls] 29 | GitHub = 'https://github.com/indico/flask-multipass' 30 | 31 | [project.entry-points.'flask_multipass.auth_providers'] 32 | ldap = 'flask_multipass.providers.ldap:LDAPAuthProvider' 33 | authlib = 'flask_multipass.providers.authlib:AuthlibAuthProvider' 34 | saml = 'flask_multipass.providers.saml:SAMLAuthProvider' 35 | shibboleth = 'flask_multipass.providers.shibboleth:ShibbolethAuthProvider' 36 | static = 'flask_multipass.providers.static:StaticAuthProvider' 37 | 38 | [project.entry-points.'flask_multipass.identity_providers'] 39 | ldap = 'flask_multipass.providers.ldap:LDAPIdentityProvider' 40 | ldap_or_authinfo = 'flask_multipass.providers.ldap:AuthFallbackLDAPIdentityProvider' 41 | authlib = 'flask_multipass.providers.authlib:AuthlibIdentityProvider' 42 | saml = 'flask_multipass.providers.saml:SAMLIdentityProvider' 43 | shibboleth = 'flask_multipass.providers.shibboleth:ShibbolethIdentityProvider' 44 | static = 'flask_multipass.providers.static:StaticIdentityProvider' 45 | 46 | [build-system] 47 | requires = ['hatchling==1.27.0'] 48 | build-backend = 'hatchling.build' 49 | 50 | [tool.hatch.build] 51 | exclude = ['docs/_build', '.github', '.python-version'] 52 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | ; exclude unrelated folders 3 | norecursedirs = 4 | .* 5 | *.egg-info 6 | docs 7 | env 8 | flask_multipass 9 | htmlcov 10 | ; exclude non-test files 11 | python_files = *_test.py test_*.py 12 | ; more verbose summary (include skip/fail/error/warning), coverage 13 | addopts = -rsfEw --cov flask_multipass --cov-report html --no-cov-on-fail 14 | ; fail if there are warnings, but ignore ones that are likely just noise 15 | filterwarnings = 16 | error 17 | ignore::UserWarning 18 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | target-version = 'py39' 2 | line-length = 120 3 | 4 | [lint] 5 | preview = true 6 | 7 | select = [ 8 | 'E', # pycodestyle 9 | 'F', # pyflakes 10 | 'N', # pep8-naming 11 | 'Q', # flake8-quotes 12 | 'RUF', # ruff 13 | 'UP', # pyupgrade 14 | 'D', # pydocstyle 15 | 'S', # flake8-bandit 16 | 'C4', # flake8-comprehensions 17 | 'INT', # flake8-gettext 18 | 'LOG', # flake8-logging 19 | 'B', # flake8-bugbear 20 | 'A001', # flake8-builtins 21 | 'COM', # flake8-commas 22 | 'T10', # flake8-debugger 23 | 'EXE', # flake8-executable 24 | 'ISC', # flake8-implicit-str-concat 25 | 'PIE', # flake8-pie 26 | 'PT', # flake8-pytest-style 27 | 'RSE', # flake8-raise 28 | 'RET504', # flake8-return 29 | 'SIM', # flake8-simplify 30 | 'TID', # flake8-tidy-imports 31 | 'PGH', # pygrep-hooks 32 | 'PL', # pylint 33 | 'TRY', # tryceratops 34 | 'PERF', # perflint 35 | 'FURB', # refurb 36 | 'I', # isort 37 | ] 38 | ignore = [ 39 | 'E226', # allow omitting whitespace around arithmetic operators 40 | 'E731', # allow assigning lambdas (it's useful for single-line functions defined inside other functions) 41 | 'N818', # not all our exceptions are errors 42 | 'RUF012', # ultra-noisy and dicts in classvars are very common 43 | 'RUF015', # not always more readable, and we don't do it for huge lists 44 | 'RUF022', # autofix messes up our formatting instead of just sorting 45 | 'UP038', # it looks kind of weird and it slower than a tuple 46 | 'D205', # too many docstrings which have no summary line 47 | 'D301', # https://github.com/astral-sh/ruff/issues/8696 48 | 'D1', # we have way too many missing docstrings :( 49 | 'D401', # too noisy (but maybe useful to go through at some point) 50 | 'D412', # we do not use section, and in click docstrings those blank lines are useful 51 | 'S101', # we use asserts outside tests, and do not run python with `-O` (also see B011) 52 | 'S113', # enforcing timeouts would likely require config in some places - maybe later 53 | 'S311', # false positives, it does not care about the context 54 | 'S324', # all our md5/sha1 usages are for non-security purposes 55 | 'S404', # useless, triggers on *all* subprocess imports 56 | 'S403', # there's already a warning on using pickle, no need to have one for the import 57 | 'S405', # we don't use lxml in unsafe ways 58 | 'S603', # useless, triggers on *all* subprocess calls: https://github.com/astral-sh/ruff/issues/4045 59 | 'S607', # we trust the PATH to be sane 60 | 'B011', # we don't run python with `-O` (also see S101) 61 | 'B904', # possibly useful but too noisy 62 | 'PIE807', # `lambda: []` is much clearer for `load_default` in schemas 63 | 'PT011', # very noisy 64 | 'PT015', # nice for tests but not so nice elsewhere 65 | 'PT018', # ^ likewise 66 | 'SIM102', # sometimes nested ifs are more readable 67 | 'SIM103', # sometimes this is more readable (especially when checking multiple conditions) 68 | 'SIM105', # try-except-pass is faster and people are used to it 69 | 'SIM108', # noisy ternary 70 | 'SIM114', # sometimes separate ifs are more readable (especially if they just return a bool) 71 | 'SIM117', # nested context managers may be more readable 72 | 'PLC0415', # local imports are there for a reason 73 | 'PLC2701', # some private imports are needed 74 | 'PLR09', # too-many- is just noisy 75 | 'PLR0913', # very noisy 76 | 'PLR2004', # extremely noisy and generally annoying 77 | 'PLR6201', # sets are faster (by a factor of 10!) but it's noisy and we're in nanoseconds territory 78 | 'PLR6301', # extremely noisy and generally annoying 79 | 'PLW0108', # a lambda often makes it more clear what you actually want 80 | 'PLW1510', # we often do not care about the status code of commands 81 | 'PLW1514', # we expect UTF8 environments everywhere 82 | 'PLW1641', # false positives with SA comparator classes 83 | 'PLW2901', # noisy and reassigning to the loop var is usually intentional 84 | 'TRY002', # super noisy, and those exceptions are pretty exceptional anyway 85 | 'TRY003', # super noisy and also useless w/ werkzeugs http exceptions 86 | 'TRY300', # kind of strange in many cases 87 | 'TRY301', # sometimes doing that is actually useful 88 | 'TRY400', # not all exceptions need exception logging 89 | 'PERF203', # noisy, false positives, and not applicable for 3.11+ 90 | 'FURB113', # less readable 91 | 'FURB140', # less readable and actually slower in 3.12+ 92 | 'PLW0603', # globals are fine here 93 | 'COM812', # formatter conflict 94 | 'ISC001', # formatter conflict 95 | ] 96 | 97 | extend-safe-fixes = [ 98 | 'RUF005', # we typically don't deal with objects overriding `__add__` ir `__radd__` 99 | 'C4', # they seem pretty safe 100 | 'UP008', # ^ likewise 101 | 'D200', # ^ likewise 102 | 'D400', # ^ likewise 103 | 'PT014', # duplicate test case parametrizations are never intentional 104 | 'RSE102', # we do not use `raise func()` (with `func` returning the exception instance) 105 | 'RET504', # looks pretty safe 106 | 'SIM110', # ^ likewise 107 | 'PERF102', # ^ likewise 108 | ] 109 | 110 | [format] 111 | quote-style = 'single' 112 | 113 | [lint.flake8-builtins] 114 | builtins-ignorelist = ['id', 'format', 'input', 'type', 'credits', 'copyright'] 115 | 116 | [lint.flake8-pytest-style] 117 | fixture-parentheses = false 118 | mark-parentheses = false 119 | parametrize-names-type = 'tuple' 120 | parametrize-values-type = 'tuple' 121 | parametrize-values-row-type = 'tuple' 122 | 123 | [lint.flake8-tidy-imports] 124 | ban-relative-imports = 'all' 125 | 126 | [lint.flake8-quotes] 127 | inline-quotes = 'single' 128 | multiline-quotes = 'double' 129 | docstring-quotes = 'double' 130 | avoid-escape = true 131 | 132 | [lint.pep8-naming] 133 | classmethod-decorators = [ 134 | 'classmethod', 135 | 'declared_attr', 136 | 'expression', 137 | 'comparator', 138 | ] 139 | 140 | [lint.pydocstyle] 141 | convention = 'pep257' 142 | 143 | [lint.per-file-ignores] 144 | 'tests/*.py' = ['F811', 'E241', 'S105', 'E272'] 145 | 'docs/conf.py' = ['E265'] 146 | 'example/example.py' = ['S104', 'S105', 'S106'] 147 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indico/flask-multipass/e44bab00f8c1fab2a57063670ebcf60ee723d9b5/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | import flask_multipass 8 | 9 | 10 | def pytest_configure(config): 11 | # Disable the support attr checks while testing 12 | attrs = ('AuthProvider', 'Group', 'IdentityProvider') 13 | for attr in attrs: 14 | getattr(flask_multipass, attr).__support_attrs__ = {} 15 | -------------------------------------------------------------------------------- /tests/providers/ldap/test_operations.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | from unittest.mock import MagicMock 8 | 9 | import pytest 10 | from ldap import NO_SUCH_OBJECT, SCOPE_BASE 11 | 12 | from flask_multipass.exceptions import GroupRetrievalFailed, IdentityRetrievalFailed 13 | from flask_multipass.providers.ldap.operations import ( 14 | get_group_by_id, 15 | get_token_groups_from_user_dn, 16 | get_user_by_id, 17 | search, 18 | ) 19 | from flask_multipass.providers.ldap.util import ldap_context 20 | 21 | 22 | def test_get_user_by_id_handles_none_id(): 23 | with pytest.raises(IdentityRetrievalFailed) as excinfo: 24 | get_user_by_id(None) 25 | assert str(excinfo.value) == 'No identifier specified' 26 | 27 | 28 | def test_get_group_by_id_handles_none_id(): 29 | with pytest.raises(GroupRetrievalFailed) as excinfo: 30 | get_group_by_id(None) 31 | assert str(excinfo.value) == 'No identifier specified' 32 | 33 | 34 | @pytest.mark.parametrize(('settings', 'base_dn', 'search_filter', 'attributes', 'mock_data', 'expected'), ( 35 | ({'uri': 'ldaps://ldap.example.com:636', 36 | 'bind_dn': 'uid=admin,DC=example,DC=com', 37 | 'bind_password': 'LemotdepassedeLDAP', 38 | 'verify_cert': True, 39 | 'cert_file': ' /etc/ssl/certs/ca-certificates.crt', 40 | 'starttls': True, 41 | 'timeout': 10, 42 | 'page_size': 3}, 43 | 'dc=example,dc=com', '(&(name=Alain)(objectCategory=user))', ['mail'], 44 | {'msg_ids': [f'msg_id<{i}>' for i in range(3)], 45 | 'cookies': [f'cookie<{i}>' for i in range(2)], 46 | 'results': ((('uid=alaina,dc=example,dc=com', {'mail': ['alaina@mail.com']}), 47 | ('uid=alainb,dc=example,dc=com', {'mail': ['alainb@mail.com']}), 48 | ('uid=alainc,dc=example,dc=com', {'mail': ['alainc@mail.com']})), 49 | (('uid=alaind,dc=example,dc=com', {'mail': ['alaind@mail.com']}), 50 | ('uid=alaine,dc=example,dc=com', {'mail': ['alaine@mail.com']}), 51 | ('uid=alainf,dc=example,dc=com', {'mail': ['alainf@mail.com']})), 52 | ((None, {'cn': ['Configuration']}), 53 | ('uid=alaing,dc=example,dc=com', {'mail': ['alaing@mail.com']}), 54 | ('uid=alainh,dc=example,dc=com', {'mail': ['alainh@mail.com']}), 55 | ('uid=alaini,dc=example,dc=com', {'mail': ['alaini@mail.com']})))}, 56 | (('uid=alaina,dc=example,dc=com', {'mail': ['alaina@mail.com']}), 57 | ('uid=alainb,dc=example,dc=com', {'mail': ['alainb@mail.com']}), 58 | ('uid=alainc,dc=example,dc=com', {'mail': ['alainc@mail.com']}), 59 | ('uid=alaind,dc=example,dc=com', {'mail': ['alaind@mail.com']}), 60 | ('uid=alaine,dc=example,dc=com', {'mail': ['alaine@mail.com']}), 61 | ('uid=alainf,dc=example,dc=com', {'mail': ['alainf@mail.com']}), 62 | ('uid=alaing,dc=example,dc=com', {'mail': ['alaing@mail.com']}), 63 | ('uid=alainh,dc=example,dc=com', {'mail': ['alainh@mail.com']}), 64 | ('uid=alaini,dc=example,dc=com', {'mail': ['alaini@mail.com']}))), 65 | )) 66 | def test_search(mocker, settings, base_dn, search_filter, attributes, mock_data, expected): 67 | mock_data['cookies'].append('') # last search operation should not return a cookie 68 | page_ctrl = MagicMock() 69 | mocker.patch('flask_multipass.providers.ldap.operations.SimplePagedResultsControl', return_value=page_ctrl) 70 | ldap_connection = MagicMock(result3=MagicMock(side_effect=((None, entries, None, [page_ctrl]) 71 | for entries in mock_data['results'])), 72 | search_ext=MagicMock(side_effect=mock_data['msg_ids'])) 73 | mocker.patch('flask_multipass.providers.ldap.util.ReconnectLDAPObject', return_value=ldap_connection) 74 | mocker.patch('flask_multipass.providers.ldap.operations.get_page_cookie', side_effect=mock_data['cookies']) 75 | 76 | with ldap_context(settings): 77 | for i, result in enumerate(search(base_dn, search_filter, attributes)): 78 | assert result == expected[i] 79 | 80 | 81 | @pytest.mark.parametrize(('settings', 'base_dn', 'search_filter', 'attributes', 'msg_ids'), ( 82 | ({'uri': 'ldaps://ldap.example.com:636', 83 | 'bind_dn': 'uid=admin,DC=example,DC=com', 84 | 'bind_password': 'LemotdepassedeLDAP', 85 | 'verify_cert': True, 86 | 'cert_file': ' /etc/ssl/certs/ca-certificates.crt', 87 | 'starttls': True, 88 | 'timeout': 10, 89 | 'page_size': 3}, 90 | 'dc=example,dc=com', 91 | '(&(name=Alain)(objectCategory=user))', 92 | ['mail'], 93 | [f'msg_id<{i}>' for i in range(3)]), 94 | )) 95 | def test_search_none_existing_entry(mocker, settings, base_dn, search_filter, attributes, msg_ids): 96 | page_ctrl = MagicMock() 97 | mocker.patch('flask_multipass.providers.ldap.operations.SimplePagedResultsControl', return_value=page_ctrl) 98 | ldap_connection = MagicMock(result3=MagicMock(side_effect=NO_SUCH_OBJECT), 99 | search_ext=MagicMock(side_effect=msg_ids)) 100 | mocker.patch('flask_multipass.providers.ldap.util.ReconnectLDAPObject', return_value=ldap_connection) 101 | 102 | with ldap_context(settings): 103 | for _result in search(base_dn, search_filter, attributes): 104 | pytest.fail('search should not yield any result') 105 | 106 | 107 | @pytest.mark.parametrize(('user_dn', 'mock_data', 'expected'), ( 108 | ('cn=ielosubmarine,OU=Users,dc=example,dc=com', 109 | [('cn=ielosubmarine,OU=Users,dc=example,dc=com', {'tokenGroups': [f'token<{i}>' for i in range(5)]})], 110 | [f'token<{i}>' for i in range(5)]), 111 | ('cn=ielosubmarine,OU=Users,dc=example,dc=com', 112 | [('cn=ielosubmarine,OU=Users,dc=example,dc=com', 113 | {'name': [b'I\xc3\xa9losubmarine'], 'tokenGroups': [f'token<{i}>' for i in range(5)]})], 114 | [f'token<{i}>' for i in range(5)]), 115 | ('cn=ielosubmarine,OU=Users,dc=example,dc=com', 116 | [(None, {'cn': ['Configuration']}), 117 | ('cn=ielosubmarine,OU=Users,dc=example,dc=com', {'name': [b'I\xc3\xa9losubmarine']})], []), 118 | ('cn=ielosubmarine,OU=Users,dc=example,dc=com', 119 | [(None, {'cn': ['Configuration']}), 120 | ('cn=ielosubmarine,OU=Users,dc=example,dc=com', {'tokenGroups': [f'token<{i}>' for i in range(5)]})], 121 | [f'token<{i}>' for i in range(5)]), 122 | ('cn=ielosubmarine,OU=Users,dc=example,dc=com', 123 | [(None, {'cn': ['Configuration']}), 124 | ('cn=ielosubmarine,OU=Users,dc=example,dc=com', 125 | {'name': [b'I\xc3\xa9losubmarine'], 'tokenGroups': [f'token<{i}>' for i in range(5)]})], 126 | [f'token<{i}>' for i in range(5)]), 127 | ('cn=ielosubmarine,OU=Users,dc=example,dc=com', 128 | [(None, {'cn': ['Configuration']})], 129 | []), 130 | )) 131 | def test_get_token_groups_from_user_dn(mocker, user_dn, mock_data, expected): 132 | settings = { 133 | 'uri': 'ldaps://ldap.example.com:636', 134 | 'bind_dn': 'uid=admin,DC=example,DC=com', 135 | 'bind_password': 'LemotdepassedeLDAP', 136 | 'verify_cert': True, 137 | 'cert_file': ' /etc/ssl/certs/ca-certificates.crt', 138 | 'starttls': True, 139 | 'timeout': 10, 140 | } 141 | 142 | ldap_search = MagicMock(return_value=mock_data) 143 | ldap_conn = MagicMock(search_ext_s=ldap_search) 144 | mocker.patch('flask_multipass.providers.ldap.util.ReconnectLDAPObject', return_value=ldap_conn) 145 | with ldap_context(settings): 146 | assert get_token_groups_from_user_dn(user_dn) == expected 147 | # Token-Groups must be retrieved from a base scope query 148 | ldap_search.assert_called_once_with(user_dn, SCOPE_BASE, sizelimit=1, timeout=settings['timeout'], 149 | attrlist=['tokenGroups']) 150 | -------------------------------------------------------------------------------- /tests/providers/ldap/test_util.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | from collections import OrderedDict 8 | from unittest.mock import MagicMock, call 9 | from urllib.parse import urlsplit 10 | 11 | import ldap 12 | import pytest 13 | 14 | from flask_multipass.exceptions import MultipassException 15 | from flask_multipass.providers.ldap.globals import current_ldap 16 | from flask_multipass.providers.ldap.util import LDAPContext, build_search_filter, find_one, ldap_context, to_unicode 17 | from flask_multipass.util import convert_app_data 18 | 19 | 20 | @pytest.mark.parametrize(('criteria', 'type_filter', 'mapping', 'exact', 'expected'), ( 21 | ({}, '', None, True, None), 22 | ({}, '', '(|(objectClass=Person)(objectCategory=user))', True, None), 23 | ({'cn': ['foobar'], 'objectSid': [b'\00\xa0foo']}, '', None, True, r'(&(cn=foobar)(objectSid=\00\a0\66\6f\6f))'), 24 | ({'givenName': ['Alain'], 'sn': ["D'Issoir"]}, '', None, True, "(&(givenName=Alain)(sn=D'Issoir))"), 25 | ({'givenName': ['Alain'], 'last_name': ["D'Issoir"]}, '', {'last_name': 'sn'}, True, 26 | "(&(givenName=Alain)(sn=D'Issoir))"), 27 | ({'first_name': ['Alain'], 'last_name': ["D'Issoir"]}, '', {'first_name': 'givenName', 'last_name': 'sn'}, True, 28 | "(&(givenName=Alain)(sn=D'Issoir))"), 29 | ({'first_name': ['Alain'], 'last_name': ["D'Issoir"]}, '(|(objectClass=Person)(objectCategory=user))', 30 | {'first_name': 'givenName', 'last_name': 'sn'}, True, 31 | "(&(givenName=Alain)(sn=D'Issoir)(|(objectClass=Person)(objectCategory=user)))"), 32 | ({}, '', None, False, None), 33 | ({}, '', '(|(objectClass=Person)(objectCategory=user))', False, None), 34 | ({'givenName': ['Alain'], 'sn': ["D'Issoir"]}, '', None, False, "(&(givenName=*Alain*)(sn=*D'Issoir*))"), 35 | ({'givenName': ['Alain'], 'last_name': ["D'Issoir"]}, '', {'last_name': 'sn'}, False, 36 | "(&(givenName=*Alain*)(sn=*D'Issoir*))"), 37 | ({'first_name': ['Alain'], 'last_name': ["D'Issoir"]}, '', {'first_name': 'givenName', 'last_name': 'sn'}, False, 38 | "(&(givenName=*Alain*)(sn=*D'Issoir*))"), 39 | ({'first_name': ['Alain'], 'last_name': ["D'Issoir"]}, '(|(objectClass=Person)(objectCategory=user))', 40 | {'first_name': 'givenName', 'last_name': 'sn'}, False, 41 | "(&(givenName=*Alain*)(sn=*D'Issoir*)(|(objectClass=Person)(objectCategory=user)))"), 42 | ({'email': ['alaindissoir@mail.com']}, '(|(objectClass=Person)(objectCategory=user))', {'email': 'mail'}, False, 43 | '(&(mail=*alaindissoir@mail.com*)(|(objectClass=Person)(objectCategory=user)))'), 44 | ({'email': ['alaindissoir@mail.com']}, '(|(objectClass=Person)(objectCategory=user))', {'email': 'mail'}, True, 45 | '(&(mail=alaindissoir@mail.com)(|(objectClass=Person)(objectCategory=user)))'), 46 | ({'email': ['alaindissoir@mail.com', 'alain@dissoir.com', 'alaindi@mail.com']}, 47 | '(|(objectClass=Person)(objectCategory=user))', {'email': 'mail'}, False, 48 | '(&(|(mail=*alaindissoir@mail.com*)(mail=*alain@dissoir.com*)(mail=*alaindi@mail.com*))' 49 | '(|(objectClass=Person)(objectCategory=user)))'), 50 | ({'email': ['alaindissoir@mail.com', 'alain@dissoir.com', 'alaindi@mail.com']}, 51 | '(|(objectClass=Person)(objectCategory=user))', {'email': 'mail'}, True, 52 | '(&(|(mail=alaindissoir@mail.com)(mail=alain@dissoir.com)(mail=alaindi@mail.com))' 53 | '(|(objectClass=Person)(objectCategory=user)))'), 54 | )) 55 | def test_build_search_filter(monkeypatch, criteria, type_filter, mapping, exact, expected): 56 | def _convert_app_data(*args, **kwargs): 57 | return OrderedDict(sorted(convert_app_data(*args, **kwargs).items())) 58 | monkeypatch.setattr('flask_multipass.providers.ldap.util.convert_app_data', _convert_app_data) 59 | assert build_search_filter(criteria, type_filter, mapping, exact) == expected 60 | 61 | 62 | @pytest.mark.parametrize(('data', 'expected'), ( 63 | ({'uid': [b'amazzing'], 'givenName': [b'Antonio'], 'sn': [b'Mazzinghy']}, 64 | {'uid': ['amazzing'], 'givenName': ['Antonio'], 'sn': ['Mazzinghy']}), 65 | ({'uid': ['poisson'], 'company': [b'Chez Ordralfab\xc3\xa9tix'], 'sn': [b'I\xc3\xa9losubmarine']}, 66 | {'uid': ['poisson'], 'company': ['Chez Ordralfab\xe9tix'], 'sn': ['I\xe9losubmarine']}), 67 | )) 68 | def test_to_unicode(data, expected): 69 | assert to_unicode(data) == expected 70 | 71 | 72 | @pytest.mark.parametrize(('settings', 'options'), ( 73 | ({'uri': 'ldaps://ldap.example.com:636', 74 | 'bind_dn': 'uid=admin,DC=example,DC=com', 75 | 'bind_password': 'LemotdepassedeLDAP', 76 | 'verify_cert': True, 77 | 'cert_file': '/etc/ssl/certs/ca-certificates.crt', 78 | 'starttls': True}, 79 | ((ldap.OPT_REFERRALS, 0), 80 | (ldap.OPT_X_TLS_CACERTFILE, '/etc/ssl/certs/ca-certificates.crt'), 81 | (ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_DEMAND), (ldap.OPT_X_TLS_NEWCTX, 0))), 82 | ({'uri': 'ldaps://ldap.example.com:636', 83 | 'bind_dn': 'uid=admin,DC=example,DC=com', 84 | 'bind_password': 'LemotdepassedeLDAP', 85 | 'verify_cert': False, 86 | 'cert_file': '/etc/ssl/certs/ca-certificates.crt', 87 | 'starttls': True}, 88 | ((ldap.OPT_REFERRALS, 0), 89 | (ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_ALLOW), (ldap.OPT_X_TLS_NEWCTX, 0))), 90 | ({'uri': 'ldaps://ldap.example.com:636', 91 | 'bind_dn': 'uid=admin,DC=example,DC=com', 92 | 'bind_password': 'LemotdepassedeLDAP', 93 | 'verify_cert': True, 94 | 'cert_file': '/etc/ssl/certs/ca-certificates.crt', 95 | 'starttls': False}, 96 | ((ldap.OPT_REFERRALS, 0), 97 | (ldap.OPT_X_TLS_CACERTFILE, '/etc/ssl/certs/ca-certificates.crt'), 98 | (ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_DEMAND), (ldap.OPT_X_TLS_NEWCTX, 0))), 99 | ({'uri': 'ldaps://ldap.example.com:636', 100 | 'bind_dn': 'uid=admin,DC=example,DC=com', 101 | 'bind_password': 'LemotdepassedeLDAP', 102 | 'verify_cert': False, 103 | 'starttls': False}, 104 | ((ldap.OPT_REFERRALS, 0), 105 | (ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_ALLOW), (ldap.OPT_X_TLS_NEWCTX, 0))), 106 | ({'uri': 'ldap://ldap.example.com:636', 107 | 'bind_dn': 'uid=admin,DC=example,DC=com', 108 | 'bind_password': 'LemotdepassedeLDAP', 109 | 'verify_cert': True, 110 | 'cert_file': '/etc/ssl/certs/ca-certificates.crt', 111 | 'starttls': True}, 112 | ((ldap.OPT_REFERRALS, 0), 113 | (ldap.OPT_X_TLS_CACERTFILE, '/etc/ssl/certs/ca-certificates.crt'), 114 | (ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_DEMAND), (ldap.OPT_X_TLS_NEWCTX, 0))), 115 | ({'uri': 'ldap://ldap.example.com:636', 116 | 'bind_dn': 'uid=admin,DC=example,DC=com', 117 | 'bind_password': 'LemotdepassedeLDAP', 118 | 'verify_cert': False, 119 | 'cert_file': '/etc/ssl/certs/ca-certificates.crt', 120 | 'starttls': True}, 121 | ((ldap.OPT_REFERRALS, 0), 122 | (ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_ALLOW), (ldap.OPT_X_TLS_NEWCTX, 0))), 123 | ({'uri': 'ldap://ldap.example.com:636', 124 | 'bind_dn': 'uid=admin,DC=example,DC=com', 125 | 'bind_password': 'LemotdepassedeLDAP', 126 | 'verify_cert': True, 127 | 'cert_file': '/etc/ssl/certs/ca-certificates.crt', 128 | 'starttls': False}, 129 | ((ldap.OPT_REFERRALS, 0), 130 | (ldap.OPT_X_TLS_CACERTFILE, '/etc/ssl/certs/ca-certificates.crt'), 131 | (ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_DEMAND), (ldap.OPT_X_TLS_NEWCTX, 0))), 132 | ({'uri': 'ldap://ldap.example.com:636', 133 | 'bind_dn': 'uid=admin,DC=example,DC=com', 134 | 'bind_password': 'LemotdepassedeLDAP', 135 | 'verify_cert': False, 136 | 'starttls': False}, 137 | ((ldap.OPT_REFERRALS, 0), 138 | (ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_ALLOW), (ldap.OPT_X_TLS_NEWCTX, 0))), 139 | )) 140 | def test_ldap_context(mocker, settings, options): 141 | warn = mocker.patch('flask_multipass.providers.ldap.util.warn') 142 | ldap_initialize = mocker.patch('flask_multipass.providers.ldap.util.ReconnectLDAPObject') 143 | ldap_conn = MagicMock() 144 | ldap_initialize.return_value = ldap_conn 145 | with ldap_context(settings) as ldap_ctx: 146 | ldap_initialize.assert_called_once_with(settings['uri'], bytes_mode=False) 147 | assert ldap_conn.protocol_version == ldap.VERSION3, 'LDAP v3 has not been set' 148 | assert ldap_conn.set_option.mock_calls == [call.set_option(*args) for args in options], 'Not all options set' 149 | if settings['starttls']: 150 | if urlsplit(settings['uri']).scheme == 'ldaps': 151 | warn.assert_called_once_with('Unable to start TLS, LDAP connection already secured over SSL (LDAPS)', 152 | stacklevel=1) 153 | else: 154 | ldap_conn.start_tls_s.assert_called_once_with() 155 | ldap_conn.simple_bind_s.assert_called_once_with(settings['bind_dn'], settings['bind_password']) 156 | assert current_ldap == ldap_ctx, 'The LDAP context has not been set as the current one' 157 | assert current_ldap == LDAPContext(connection=ldap_conn, settings=settings) 158 | assert not current_ldap, 'The LDAP context has not been unset' 159 | 160 | 161 | @pytest.mark.parametrize(('method', 'triggered_exception', 'caught_exception', 'message'), ( 162 | ('search_s', ldap.SERVER_DOWN, MultipassException, 'The LDAP server is unreachable'), 163 | ('simple_bind_s', ldap.INVALID_CREDENTIALS, ValueError, 'Invalid bind credentials'), 164 | ('simple_bind_s', ldap.SIZELIMIT_EXCEEDED, MultipassException, 165 | 'Size limit exceeded (try setting a smaller page size)'), 166 | ('simple_bind_s', ldap.TIMELIMIT_EXCEEDED, MultipassException, 167 | 'The time limit for the operation has been exceeded.'), 168 | ('simple_bind_s', ldap.TIMEOUT, MultipassException, 'The operation timed out.'), 169 | ('simple_bind_s', ldap.FILTER_ERROR, ValueError, 170 | 'The filter supplied to the operation is invalid. (This is most likely due to a bad user or group filter.'), 171 | )) 172 | def test_ldap_context_invalid_credentials(mocker, method, triggered_exception, caught_exception, message): 173 | settings = { 174 | 'uri': 'ldaps://ldap.example.com:636', 175 | 'bind_dn': 'uid=admin,DC=example,DC=com', 176 | 'bind_password': 'LemotdepassedeLDAP', 177 | 'verify_cert': True, 178 | 'cert_file': '/etc/ssl/certs/ca-certificates.crt', 179 | 'starttls': True, 180 | } 181 | 182 | ldap_initialize = mocker.patch('flask_multipass.providers.ldap.util.ReconnectLDAPObject') 183 | ldap_conn = MagicMock() 184 | getattr(ldap_conn, method).side_effect = triggered_exception 185 | ldap_initialize.return_value = ldap_conn 186 | 187 | with pytest.raises(caught_exception) as excinfo: 188 | with ldap_context(settings): 189 | current_ldap.connection.search_s('dc=example,dc=com', ldap.SCOPE_SUBTREE) 190 | assert str(excinfo.value) == message 191 | 192 | 193 | @pytest.mark.parametrize(('base_dn', 'search_filter', 'data', 'expected'), ( 194 | ('dc=example,dc=com', '(&(mail=alain.dissoir@mail.com)(objectCategory=user))', 195 | [('cn=alaindi,OU=Users,dc=example,dc=com', {'mail': ['alain.dissoir@mail.com'], 'name': ["Alain D'issoir"]})], 196 | ('cn=alaindi,OU=Users,dc=example,dc=com', {'mail': ['alain.dissoir@mail.com'], 'name': ["Alain D'issoir"]})), 197 | ('dc=example,dc=com', '(&(mail=alain.dissoir@mail.com)(objectCategory=user))', 198 | [(None, {'cn': ['Configuration']}), 199 | ('cn=alaindi,OU=Users,dc=example,dc=com', {'mail': ['alain.dissoir@mail.com'], 'name': ["Alain D'issoir"]})], 200 | ('cn=alaindi,OU=Users,dc=example,dc=com', {'mail': ['alain.dissoir@mail.com'], 'name': ["Alain D'issoir"]})), 201 | ('dc=example,dc=com', '(&(mail=alain.dissoir@mail.com)(objectCategory=user))', 202 | [(None, {'cn': ['Configuration']})], (None, None)), 203 | ('dc=example,dc=com', '(&(mail=alain.dissoir@mail.com)(objectCategory=user))', 204 | [(None, None), (None, {'cn': ['Configuration']})], (None, None)), 205 | )) 206 | def test_find_one(mocker, base_dn, search_filter, data, expected): 207 | settings = { 208 | 'uri': 'ldaps://ldap.example.com:636', 209 | 'bind_dn': 'uid=admin,DC=example,DC=com', 210 | 'bind_password': 'LemotdepassedeLDAP', 211 | 'verify_cert': True, 212 | 'cert_file': '/etc/ssl/certs/ca-certificates.crt', 213 | 'starttls': True, 214 | 'timeout': 10, 215 | } 216 | 217 | ldap_search = MagicMock(return_value=data) 218 | ldap_conn = MagicMock(search_ext_s=ldap_search) 219 | mocker.patch('flask_multipass.providers.ldap.util.ReconnectLDAPObject', return_value=ldap_conn) 220 | 221 | with ldap_context(settings): 222 | assert find_one(base_dn, search_filter) == expected 223 | -------------------------------------------------------------------------------- /tests/test_auth.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | import pytest 8 | 9 | from flask_multipass import AuthProvider 10 | 11 | 12 | class LocalProvider(AuthProvider): 13 | login_form = object() 14 | 15 | 16 | class RemoteProvider(AuthProvider): 17 | pass 18 | 19 | 20 | def test_is_external(): 21 | assert not LocalProvider(None, None, {}).is_external 22 | assert RemoteProvider(None, None, {}).is_external 23 | 24 | 25 | def test_settings_copied(): 26 | settings = {'foo': 'bar'} 27 | provider = LocalProvider(None, None, settings) 28 | provider.settings['foo'] = 'foobar' 29 | assert settings['foo'] == 'bar' 30 | 31 | 32 | @pytest.mark.parametrize(('settings', 'title'), ( 33 | ({}, 'foo'), 34 | ({'title': 'whatever'}, 'whatever'), 35 | )) 36 | def test_settings_title(settings, title): 37 | provider = LocalProvider(None, 'foo', settings) 38 | assert provider.title == title 39 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | from unittest.mock import Mock 8 | 9 | import pytest 10 | from flask import Flask, request, session 11 | 12 | from flask_multipass import AuthenticationFailed, AuthProvider, Multipass 13 | 14 | 15 | def test_init_app_twice(): 16 | multipass = Multipass() 17 | app = Flask('test') 18 | multipass.init_app(app) 19 | with pytest.raises(RuntimeError): 20 | multipass.init_app(app) 21 | 22 | 23 | def test_init_app_late(): 24 | app = Flask('text') 25 | multipass = Multipass() 26 | multipass.init_app(app) 27 | assert app.extensions['multipass'].multipass is multipass 28 | 29 | 30 | def test_init_app_immediately(): 31 | app = Flask('test') 32 | multipass = Multipass(app) 33 | assert app.extensions['multipass'].multipass is multipass 34 | 35 | 36 | def test_multiple_apps(): 37 | apps = Flask('test'), Flask('test') 38 | multipass = Multipass() 39 | for app in apps: 40 | multipass.init_app(app) 41 | # The separate loop here is on purpose as the extension needs to 42 | # be present on all apps after initializing them 43 | for app in apps: 44 | assert app.extensions['multipass'].multipass is multipass 45 | 46 | 47 | class FooProvider(AuthProvider): 48 | pass 49 | 50 | 51 | class UniqueProvider(AuthProvider): 52 | multi_instance = False 53 | 54 | 55 | def test_initialize_providers(): 56 | app = Flask('test') 57 | app.config['MULTIPASS_AUTH_PROVIDERS'] = { 58 | 'test': {'type': 'foo', 'foo': 'bar'}, 59 | 'test2': {'type': 'unique', 'hello': 'world'}, 60 | } 61 | multipass = Multipass() 62 | multipass.register_provider(FooProvider, 'foo') 63 | multipass.register_provider(UniqueProvider, 'unique') 64 | with app.app_context(): 65 | auth_providers = multipass._create_providers('AUTH', AuthProvider) 66 | assert auth_providers['test'].settings == {'foo': 'bar'} 67 | assert auth_providers['test2'].settings == {'hello': 'world'} 68 | 69 | 70 | def test_initialize_providers_unique(): 71 | app = Flask('test') 72 | app.config['MULTIPASS_AUTH_PROVIDERS'] = { 73 | 'test': {'type': 'unique', 'foo': 'bar'}, 74 | 'test2': {'type': 'unique', 'hello': 'world'}, 75 | } 76 | multipass = Multipass() 77 | multipass.register_provider(FooProvider, 'foo') 78 | multipass.register_provider(UniqueProvider, 'unique') 79 | with pytest.raises(RuntimeError): 80 | multipass.init_app(app) 81 | 82 | 83 | def test_create_login_rule(mocker): 84 | process_login = mocker.patch.object(Multipass, 'process_login') 85 | app = Flask('test') 86 | Multipass(app) 87 | with app.test_client() as c: 88 | for url in app.config['MULTIPASS_LOGIN_URLS']: 89 | c.get(url) 90 | assert process_login.call_count == 2 91 | 92 | 93 | def test_create_login_rule_disabled(mocker): 94 | process_login = mocker.patch.object(Multipass, 'process_login') 95 | app = Flask('test') 96 | app.config['MULTIPASS_LOGIN_URLS'] = None 97 | Multipass(app) 98 | with app.test_client() as c: 99 | for url in ('/login/', '/login/'): 100 | assert c.get(url).status_code == 404 101 | assert not process_login.called 102 | 103 | 104 | def test_render_template(mocker): 105 | render_template = mocker.patch('flask_multipass.core.render_template') 106 | app = Flask('test') 107 | app.config['MULTIPASS_FOO_TEMPLATE'] = None 108 | app.config['MULTIPASS_BAR_TEMPLATE'] = 'bar.html' 109 | multipass = Multipass(app) 110 | with app.app_context(): 111 | with pytest.raises(RuntimeError): 112 | multipass.render_template('FOO', foo='bar') 113 | multipass.render_template('BAR', foo='bar') 114 | render_template.assert_called_with('bar.html', foo='bar') 115 | 116 | 117 | def test_next_url(): 118 | app = Flask('test') 119 | app.add_url_rule('/success', 'success') 120 | app.config['SECRET_KEY'] = 'testing' 121 | app.config['MULTIPASS_SUCCESS_ENDPOINT'] = 'success' 122 | multipass = Multipass(app) 123 | with app.test_request_context(): 124 | # default url - not in session 125 | assert multipass._get_next_url() == '/success' 126 | multipass.set_next_url() 127 | # default url - in session 128 | assert multipass._get_next_url() == '/success' 129 | request.args = {'next': '/private'} 130 | # next url specified, but not in session yet 131 | assert multipass._get_next_url() == '/success' 132 | multipass.set_next_url() 133 | # removed from session after retrieving it once 134 | assert multipass._get_next_url() == '/private' 135 | assert multipass._get_next_url() == '/success' 136 | 137 | 138 | def test_next_url_invalid(): 139 | app = Flask('test') 140 | app.add_url_rule('/success', 'success') 141 | app.config['SECRET_KEY'] = 'testing' 142 | app.config['MULTIPASS_SUCCESS_ENDPOINT'] = 'success' 143 | multipass = Multipass(app) 144 | with app.test_request_context(): 145 | request.args = {'next': '//evil.com'} 146 | multipass.set_next_url() 147 | assert multipass._get_next_url() == '/success' 148 | 149 | 150 | @pytest.mark.parametrize(('url', 'valid'), ( 151 | ('foo', True), 152 | ('/foo', True), 153 | ('/foo?bar', True), 154 | ('/foo#bar', True), 155 | ('//localhost', True), 156 | ('//localhost/foo', True), 157 | ('http://localhost', True), 158 | ('https://localhost/', True), 159 | ('\n', False), 160 | ('\r', False), 161 | ('\n\r', False), 162 | ('evil\n', False), 163 | ('//evil', False), 164 | ('//evil.com', False), 165 | ('//evil.com:80', False), 166 | ('http://evil.com', False), 167 | ('https://evil.com', False), 168 | (r'http:\\evil.com', False), 169 | (r'http:\evil.com', False), 170 | (r'https:\\evil.com', False), 171 | (r'https:\evil.com', False), 172 | ('javascript:alert("eeeeeeeevil")', False), 173 | )) 174 | def test_validate_next_url(url, valid): 175 | app = Flask('test') 176 | multipass = Multipass(app) 177 | with app.test_request_context(): 178 | assert multipass.validate_next_url(url) == valid 179 | 180 | 181 | def test_login_finished(): 182 | multipass = Multipass() 183 | with pytest.raises(AssertionError): 184 | multipass.login_finished(None) 185 | callback = Mock() 186 | multipass.identity_handler(callback) 187 | multipass.login_finished('foo') 188 | callback.assert_called_with('foo') 189 | 190 | 191 | def test_login_finished_returns(): 192 | multipass = Multipass() 193 | multipass.identity_handler(Mock(return_value='bar')) 194 | assert multipass.login_finished('foo') == 'bar' 195 | 196 | 197 | def test_identity_handler(): 198 | multipass = Multipass() 199 | callback = Mock() 200 | assert multipass.identity_handler(callback) is callback 201 | 202 | 203 | def test_login_check(): 204 | multipass = Multipass() 205 | callback = Mock() 206 | assert multipass.login_check(callback) is callback 207 | 208 | 209 | def test_handle_auth_error(mocker): 210 | flash = mocker.patch('flask_multipass.core.flash') 211 | app = Flask('test') 212 | app.config['SECRET_KEY'] = 'testing' 213 | multipass = Multipass(app) 214 | with app.test_request_context(): 215 | multipass.handle_auth_error(AuthenticationFailed()) 216 | assert flash.called 217 | assert session['_multipass_auth_failed'] 218 | 219 | 220 | def test_handle_auth_error_with_redirect(mocker): 221 | flash = mocker.patch('flask_multipass.core.flash') 222 | redirect = mocker.patch('flask_multipass.core.redirect') 223 | app = Flask('test') 224 | app.config['SECRET_KEY'] = 'testing' 225 | multipass = Multipass(app) 226 | with app.test_request_context(): 227 | multipass.handle_auth_error(AuthenticationFailed(), redirect_to_login=True) 228 | assert flash.called 229 | redirect.assert_called_with(app.config['MULTIPASS_LOGIN_URLS'][0]) 230 | 231 | 232 | def test_load_providers_from_entrypoints(): 233 | app = Flask('test') 234 | app.config['SECRET_KEY'] = 'testing' 235 | app.config['MULTIPASS_AUTH_PROVIDERS'] = {'test': {'type': 'static'}} 236 | app.config['MULTIPASS_IDENTITY_PROVIDERS'] = {'test': {'type': 'static'}} 237 | app.config['MULTIPASS_PROVIDER_MAP'] = {'test': 'test'} 238 | Multipass(app) 239 | -------------------------------------------------------------------------------- /tests/test_data.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | from unittest.mock import MagicMock 8 | 9 | import pytest 10 | 11 | from flask_multipass import AuthInfo, AuthProvider, IdentityInfo, Multipass 12 | 13 | 14 | @pytest.fixture(name='dummy_auth_provider') 15 | def dummy_auth_provider_fixture(): 16 | return AuthProvider(Multipass(), 'dummy', {}) 17 | 18 | 19 | def test_authinfo(dummy_auth_provider): 20 | with pytest.raises(ValueError): 21 | AuthInfo(dummy_auth_provider) 22 | ai = AuthInfo(dummy_auth_provider, foo='bar') 23 | assert ai.data == {'foo': 'bar'} 24 | 25 | 26 | @pytest.mark.parametrize(('mapping', 'output_data'), ( 27 | ({}, {'foo': 'bar', 'meow': 1337}), 28 | ({'oof': 'foo'}, {'oof': 'bar', 'meow': 1337}), 29 | )) 30 | def test_authinfo_map(dummy_auth_provider, mapping, output_data): 31 | ai = AuthInfo(dummy_auth_provider, foo='bar', meow=1337) 32 | original_data = ai.data.copy() 33 | ai2 = ai.map(mapping) 34 | assert ai2.data == output_data 35 | assert ai.data == original_data 36 | 37 | 38 | def test_authinfo_map_invalid(dummy_auth_provider): 39 | ai = AuthInfo(dummy_auth_provider, foo='bar') 40 | with pytest.raises(KeyError): 41 | ai.map({'foo': 'nop'}) 42 | 43 | 44 | def test_identityinfo_identifier_string(): 45 | assert IdentityInfo(MagicMock(), 123).identifier == '123' 46 | -------------------------------------------------------------------------------- /tests/test_identity.py: -------------------------------------------------------------------------------- 1 | # This file is part of Flask-Multipass. 2 | # Copyright (C) 2015 - 2021 CERN 3 | # 4 | # Flask-Multipass is free software; you can redistribute it 5 | # and/or modify it under the terms of the Revised BSD License. 6 | 7 | import pytest 8 | from flask import Flask 9 | 10 | from flask_multipass import IdentityProvider, Multipass 11 | 12 | 13 | def test_settings_copied(): 14 | app = Flask('test') 15 | Multipass(app) 16 | with app.app_context(): 17 | settings = {'foo': 'bar'} 18 | provider = IdentityProvider(None, None, settings) 19 | provider.settings['foo'] = 'foobar' 20 | assert settings['foo'] == 'bar' 21 | 22 | 23 | @pytest.mark.parametrize(('settings', 'title'), ( 24 | ({}, 'foo'), 25 | ({'title': 'whatever'}, 'whatever'), 26 | )) 27 | def test_settings_title(settings, title): 28 | app = Flask('test') 29 | Multipass(app) 30 | with app.app_context(): 31 | provider = IdentityProvider(None, 'foo', settings) 32 | assert provider.title == title 33 | 34 | 35 | @pytest.mark.parametrize(('criteria', 'mapping', 'result'), ( 36 | ({'foo': 'bar'}, {}, {'foo': 'bar'}), 37 | ({'foo': 'bar'}, {'foo': 'moo'}, {'moo': 'bar'}), 38 | ({'foo': 'bar'}, {'foo': 'moo', 'bar': 'moo'}, {'moo': 'bar'}), 39 | )) 40 | def test_map_search_criteria(criteria, mapping, result): 41 | app = Flask('test') 42 | Multipass(app) 43 | with app.app_context(): 44 | settings = {'mapping': mapping} 45 | provider = IdentityProvider(None, 'foo', settings) 46 | assert provider.map_search_criteria(criteria) == result 47 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{39,310,311,312,313} 4 | style 5 | skip_missing_interpreters = true 6 | 7 | [testenv] 8 | commands = pytest 9 | extras = 10 | authlib 11 | ldap 12 | sqlalchemy 13 | dev 14 | 15 | [testenv:style] 16 | skip_install = true 17 | deps = ruff 18 | commands = ruff check --output-format github . 19 | --------------------------------------------------------------------------------