├── .codecov.yml ├── .coveragerc ├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ ├── codecov.yml │ ├── publish_to_pypi.yml │ └── testing.yml ├── .gitignore ├── .readthedocs.yaml ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── Vagrantfile ├── demo ├── adfs │ ├── manage.py │ ├── mysite │ │ ├── __init__.py │ │ ├── settings.py │ │ ├── urls.py │ │ └── wsgi.py │ ├── polls │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── api │ │ │ ├── __init__.py │ │ │ ├── filters.py │ │ │ ├── serializers.py │ │ │ ├── urls.py │ │ │ └── views.py │ │ ├── apps.py │ │ ├── migrations │ │ │ ├── 0001_initial.py │ │ │ └── __init__.py │ │ ├── models.py │ │ ├── static │ │ │ └── bootstrap.min.css │ │ ├── templates │ │ │ ├── admin │ │ │ │ └── base_site.html │ │ │ └── polls │ │ │ │ ├── detail.html │ │ │ │ ├── index.html │ │ │ │ └── vote.html │ │ ├── urls.py │ │ └── views.py │ └── templates │ │ ├── base.html │ │ └── home.html └── formsbased │ ├── manage.py │ ├── mysite │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py │ ├── polls │ ├── __init__.py │ ├── admin.py │ ├── api │ │ ├── __init__.py │ │ ├── filters.py │ │ ├── serializers.py │ │ ├── urls.py │ │ └── views.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── static │ │ └── bootstrap.min.css │ ├── templates │ │ ├── admin │ │ │ └── base_site.html │ │ └── polls │ │ │ ├── detail.html │ │ │ ├── index.html │ │ │ └── vote.html │ ├── urls.py │ └── views.py │ └── templates │ ├── base.html │ ├── home.html │ └── registration │ ├── logged_out.html │ └── login.html ├── django_auth_adfs ├── __init__.py ├── backend.py ├── config.py ├── drf-urls.py ├── drf_urls.py ├── exceptions.py ├── middleware.py ├── rest_framework.py ├── signals.py ├── templates │ └── django_auth_adfs │ │ └── login_failed.html ├── urls.py └── views.py ├── docs ├── Makefile ├── _static │ ├── 2012 │ │ ├── 01_add_relying_party.png │ │ ├── 02_add_relying_party_wizard_page1.png │ │ ├── 03_add_relying_party_wizard_page2.png │ │ ├── 04_add_relying_party_wizard_page3.png │ │ ├── 05_add_relying_party_wizard_page4.png │ │ ├── 06_add_relying_party_wizard_page5.png │ │ ├── 07_add_relying_party_wizard_page6.png │ │ ├── 08_relying_party_id.png │ │ ├── 09_add_relying_party_wizard_page8.png │ │ ├── 10_add_relying_party_wizard_page9.png │ │ ├── 11_add_relying_party_wizard_review.png │ │ ├── 12_add_relying_party_wizard_page11.png │ │ ├── 13_configure_claims_page1.png │ │ ├── 14_configure_claims_page2.png │ │ ├── 15_configure_claims_page3.png │ │ └── 16_configure_claims_page4.png │ ├── 2016 │ │ ├── 01_add_app_group.png │ │ ├── 02_add_app_group_wizard_page1.png │ │ ├── 03_add_native_app.png │ │ ├── 04_native_app_access_policy.png │ │ ├── 05_review_settings.png │ │ ├── 06_wizard_end.png │ │ ├── 07_app_group_settings.png │ │ ├── 08_add_claim_rules.png │ │ ├── 08_add_ldap_attributes_part1.png │ │ └── 08_add_ldap_attributes_part2.png │ └── AzureAD │ │ ├── 01-azure_active_directory.png │ │ ├── 02-azure_dashboard.png │ │ ├── 03-new_registrations.png │ │ ├── 04-app_registrations_specs.png │ │ ├── 05-application_overview.png │ │ ├── 06-add_Secret.png │ │ ├── 07-add_Secret_name.png │ │ ├── 08-copy_Secret.png │ │ ├── 09_register_frontend_app.PNG │ │ ├── 10_copy-frontend-client_id.png │ │ ├── 11-navigate_to_expose_an_api.PNG │ │ ├── 13_set_app_id.PNG │ │ ├── 14_add_a_scope.PNG │ │ ├── 15_add_authorized_app_1.png │ │ ├── 16_add_authorized_app_2.PNG │ │ ├── 17_navigate_to_api_permissions.PNG │ │ ├── 18_add_permission.PNG │ │ ├── 19_add-permission-2.PNG │ │ └── 20_add-permission-3.png ├── _templates │ └── .gitkeep ├── adfs_3.0_config_guide.rst ├── adfs_4.0_config_guide.rst ├── azure_ad_config_guide.rst ├── conf.py ├── config_guides.rst ├── contributing.rst ├── demo.rst ├── faq.rst ├── index.rst ├── install.rst ├── make.bat ├── middleware.rst ├── oauth2_explained.rst ├── requirements.txt ├── rest_framework.rst ├── settings_ref.rst ├── signals.rst └── troubleshooting.rst ├── manage.py ├── poetry.lock ├── pyproject.toml ├── setup.cfg ├── tests ├── __init__.py ├── custom_config.py ├── mock_files │ ├── FederationMetadata.xml │ ├── adfs-openid-configuration.json │ ├── azure-openid-configuration-v2.json │ └── azure-openid-configuration.json ├── models.py ├── settings.py ├── test_authentication.py ├── test_drf_integration.py ├── test_settings.py ├── urls.py ├── utils.py └── views.py └── vagrant ├── 01-setup-domain.ps1 ├── 02-setup-vagrant-user.ps1 ├── 03-setup-adfs.ps1 ├── 04-example-adfs-config.ps1 ├── New-SelfSignedCertificateEx.ps1 └── README.rst /.codecov.yml: -------------------------------------------------------------------------------- 1 | # Docs: https://docs.codecov.io/docs/codecovyml-reference 2 | 3 | codecov: 4 | require_ci_to_pass: yes 5 | 6 | coverage: 7 | precision: 1 8 | round: down 9 | status: 10 | project: 11 | default: 12 | target: auto 13 | patch: no 14 | changes: no 15 | 16 | comment: 17 | layout: "diff,files" 18 | require_changes: yes 19 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = django_auth_adfs 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | end_of_line = lf 11 | charset = utf-8 12 | 13 | # Use 2 spaces for the HTML files 14 | [*.html] 15 | indent_size = 2 16 | 17 | # The JSON files contain newlines inconsistently 18 | [*.json] 19 | indent_size = 2 20 | insert_final_newline = ignore 21 | 22 | [**/admin/js/vendor/**] 23 | indent_style = ignore 24 | indent_size = ignore 25 | 26 | # Minified JavaScript files shouldn't be changed 27 | [**.min.js] 28 | indent_style = ignore 29 | insert_final_newline = ignore 30 | 31 | # Makefiles always use tabs for indentation 32 | [Makefile] 33 | indent_style = tab 34 | 35 | # Batch files use tabs for indentation 36 | [*.bat] 37 | indent_style = tab 38 | 39 | [*.txt] 40 | insert_final_newline = false 41 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [jobec, jonasks, sondrelg] 2 | -------------------------------------------------------------------------------- /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | name: coverage 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | codecov: 11 | # --------------------------------------------------- 12 | # Documentation and examples can be found at 13 | # https://github.com/snok/install-poetry 14 | # --------------------------------------------------- 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.10.5" 21 | - name: Install poetry 22 | uses: snok/install-poetry@v1 23 | with: 24 | virtualenvs-in-project: true 25 | - name: Load cached venv 26 | id: cached-poetry-dependencies 27 | uses: actions/cache@v3 28 | with: 29 | path: .venv 30 | key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}-1 31 | - name: Install dependencies 32 | run: poetry install 33 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 34 | - name: Test with Django test 35 | run: | 36 | poetry run coverage run manage.py test -v 2 37 | poetry run coverage xml 38 | - name: Upload coverage 39 | uses: codecov/codecov-action@v2 40 | with: 41 | file: ./coverage.xml 42 | fail_ci_if_error: true 43 | -------------------------------------------------------------------------------- /.github/workflows/publish_to_pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish django-auth-adfs to PyPI 📦 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build-and-publish: 9 | name: Build and publish 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Set up Python 3.9 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: 3.9 17 | - name: Install poetry 18 | uses: snok/install-poetry@v1 19 | - name: Build and publish 20 | run: | 21 | poetry config pypi-token.pypi ${{ secrets.pypi_password }} 22 | poetry publish --build --no-interaction 23 | -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | linting: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: "3" 17 | - uses: actions/cache@v3 18 | with: 19 | path: ~/.cache/pip 20 | key: ${{ runner.os }}-pip 21 | restore-keys: | 22 | ${{ runner.os }}-pip- 23 | ${{ runner.os }}- 24 | - run: python -m pip install flake8 25 | - run: | 26 | flake8 . 27 | 28 | test: 29 | runs-on: ubuntu-latest 30 | strategy: 31 | fail-fast: false 32 | matrix: 33 | python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13"] 34 | django-version: [ "4.2", "5.0", "5.1", "5.2"] 35 | drf-version: ["3.14", "3.15", "3.16"] 36 | exclude: 37 | # Python 3.9 is incompatible with Django v5+ 38 | - django-version: 5.0 39 | python-version: 3.9 40 | - django-version: 5.1 41 | python-version: 3.9 42 | - django-version: 5.2 43 | python-version: 3.9 44 | # Django 4.2 is incompatible with Python 3.13+ 45 | - django-version: 4.2 46 | python-version: 3.13 47 | steps: 48 | - uses: actions/checkout@v3 49 | - uses: actions/setup-python@v5 50 | with: 51 | python-version: ${{ matrix.python-version }} 52 | allow-prereleases: true 53 | - uses: snok/install-poetry@v1 54 | with: 55 | virtualenvs-in-project: true 56 | - name: Load cached venv 57 | id: cached-poetry-dependencies 58 | uses: actions/cache@v3 59 | with: 60 | path: .venv 61 | key: ${{ hashFiles('**/poetry.lock') }}-${{ matrix.python-version }}-0 62 | - run: poetry env use ${{ matrix.python-version }} && poetry install --no-root 63 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 64 | - run: | 65 | source .venv/bin/activate 66 | pip install "Django~=${{ matrix.django-version }}.0a1" 67 | pip install "djangorestframework~=${{ matrix.drf-version }}.0" 68 | - name: Run tests 69 | run: | 70 | source .venv/bin/activate 71 | poetry run coverage run manage.py test -v 2 72 | poetry run coverage report -m 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # based on: 2 | # https://github.com/github/gitignore/blob/master/Python.gitignore 3 | # https://www.jetbrains.com/pycharm/help/managing-projects-under-version-control.html 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | env/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *,cover 50 | .hypothesis/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | db.sqlite3 59 | 60 | # Sphinx documentation 61 | docs/_build/ 62 | 63 | # PyBuilder 64 | target/ 65 | 66 | # PyCharm 67 | # https://www.jetbrains.com/pycharm/help/managing-projects-under-version-control.html 68 | .idea/ 69 | 70 | # Virtual env 71 | .venv/ 72 | 73 | # Vagrant 74 | .vagrant/ 75 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the version of Python and other tools you might need 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.11" 12 | 13 | # Build documentation in the docs/ directory with Sphinx 14 | sphinx: 15 | configuration: docs/conf.py 16 | 17 | # We recommend specifying your dependencies to enable reproducible builds: 18 | # https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 19 | python: 20 | install: 21 | - requirements: docs/requirements.txt 22 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. 6 | 7 | Get Started! 8 | ------------ 9 | 10 | Types of Contributions 11 | ---------------------- 12 | You can contribute in many ways: 13 | 14 | Report Bugs 15 | ~~~~~~~~~~~ 16 | 17 | Report bugs in the issue section of the repository on GitHub. 18 | 19 | If you are reporting a bug, please include: 20 | 21 | * Detailed steps to reproduce the bug. 22 | * Any details about your local setup that might be helpful in troubleshooting. 23 | 24 | Fix Bugs 25 | ~~~~~~~~ 26 | 27 | Look through the issues for bugs. Anything tagged with "bug" is open to whoever wants to implement it. 28 | 29 | Implement Features 30 | ~~~~~~~~~~~~~~~~~~ 31 | 32 | Look through the issues for features. Anything tagged with "feature" is open to whoever wants to implement it. 33 | 34 | Write Documentation 35 | ~~~~~~~~~~~~~~~~~~~ 36 | 37 | We could always use more documentation, whether as part of the docs or in docstrings in the code. 38 | 39 | Submit Feedback 40 | ~~~~~~~~~~~~~~~ 41 | 42 | The best way to send feedback is to file an issue on GitHub. 43 | 44 | If you are proposing a feature: 45 | 46 | * Explain in detail how it would work. 47 | * Keep the scope as narrow as possible, to make it easier to implement. 48 | 49 | Set up your environment 50 | ~~~~~~~~~~~~~~~~~~~~~~~ 51 | 1. Fork the upstream django-auth-adfs repository into a personal account. 52 | 53 | 2. Install poetry running ``pip install poetry`` or ``curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python -`` 54 | 55 | 3. Configure poetry to create a virtual environment in your project folder: ``poetry config virtualenvs.in-project true`` 56 | 57 | 3. Install dependencies by running ``poetry install`` 58 | 59 | 4. Create a new branch for your changes 60 | 61 | 5. Push the topic branch to your personal fork 62 | 63 | 6. Create a pull request to the django-auth-adfs repository with a detailed explanation 64 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Joris Beckers 2 | 3 | Redistribution and use in source and binary forms, with or without modification, 4 | are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, this 10 | list of conditions and the following disclaimer in the documentation and/or 11 | other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 17 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 18 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 19 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 20 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 21 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 22 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CONTRIBUTING.rst 2 | include README.rst 3 | 4 | recursive-include tests * 5 | recursive-exclude * __pycache__ 6 | recursive-exclude * *.py[co] 7 | 8 | recursive-include django_auth_adfs/templates *.html 9 | recursive-include docs *.rst conf.py Makefile make.bat 10 | recursive-include docs/_static * 11 | recursive-include docs/_templates * 12 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ADFS Authentication for Django 2 | ============================== 3 | 4 | .. image:: https://readthedocs.org/projects/django-auth-adfs/badge/?version=latest 5 | :target: http://django-auth-adfs.readthedocs.io/en/latest/?badge=latest 6 | :alt: Documentation Status 7 | .. image:: https://img.shields.io/pypi/v/django-auth-adfs.svg 8 | :target: https://pypi.python.org/pypi/django-auth-adfs 9 | .. image:: https://img.shields.io/pypi/pyversions/django-auth-adfs.svg 10 | :target: https://pypi.python.org/pypi/django-auth-adfs#downloads 11 | .. image:: https://img.shields.io/pypi/djversions/django-auth-adfs.svg 12 | :target: https://pypi.python.org/pypi/django-auth-adfs 13 | .. image:: https://codecov.io/github/snok/django-auth-adfs/coverage.svg?branch=main 14 | :target: https://codecov.io/github/snok/django-auth-adfs?branch=main 15 | 16 | A Django authentication backend for Microsoft ADFS and Azure AD 17 | 18 | * Free software: BSD License 19 | * Homepage: https://github.com/snok/django-auth-adfs 20 | * Documentation: http://django-auth-adfs.readthedocs.io/ 21 | 22 | Features 23 | -------- 24 | 25 | * Integrates Django with Active Directory on Windows 2012 R2, 2016 or Azure AD in the cloud. 26 | * Provides seamless single sign on (SSO) for your Django project on intranet environments. 27 | * Auto creates users and adds them to Django groups based on info received from ADFS. 28 | * Django Rest Framework (DRF) integration: Authenticate against your API with an ADFS access token. 29 | 30 | Installation 31 | ------------ 32 | 33 | Python package:: 34 | 35 | pip install django-auth-adfs 36 | 37 | In your project's ``settings.py`` add these settings. 38 | 39 | .. code-block:: python 40 | 41 | AUTHENTICATION_BACKENDS = ( 42 | ... 43 | 'django_auth_adfs.backend.AdfsAuthCodeBackend', 44 | ... 45 | ) 46 | 47 | INSTALLED_APPS = ( 48 | ... 49 | # Needed for the ADFS redirect URI to function 50 | 'django_auth_adfs', 51 | ... 52 | 53 | # checkout the documentation for more settings 54 | AUTH_ADFS = { 55 | "SERVER": "adfs.yourcompany.com", 56 | "CLIENT_ID": "your-configured-client-id", 57 | "RELYING_PARTY_ID": "your-adfs-RPT-name", 58 | # Make sure to read the documentation about the AUDIENCE setting 59 | # when you configured the identifier as a URL! 60 | "AUDIENCE": "microsoft:identityserver:your-RelyingPartyTrust-identifier", 61 | "CA_BUNDLE": "/path/to/ca-bundle.pem", 62 | "CLAIM_MAPPING": {"first_name": "given_name", 63 | "last_name": "family_name", 64 | "email": "email"}, 65 | } 66 | 67 | # Configure django to redirect users to the right URL for login 68 | LOGIN_URL = "django_auth_adfs:login" 69 | LOGIN_REDIRECT_URL = "/" 70 | 71 | ######################## 72 | # OPTIONAL SETTINGS 73 | ######################## 74 | 75 | MIDDLEWARE = ( 76 | ... 77 | # With this you can force a user to login without using 78 | # the LoginRequiredMixin on every view class 79 | # 80 | # You can specify URLs for which login is not enforced by 81 | # specifying them in the LOGIN_EXEMPT_URLS setting. 82 | 'django_auth_adfs.middleware.LoginRequiredMiddleware', 83 | ) 84 | 85 | In your project's ``urls.py`` add these paths: 86 | 87 | .. code-block:: python 88 | 89 | urlpatterns = [ 90 | ... 91 | path('oauth2/', include('django_auth_adfs.urls')), 92 | ] 93 | 94 | This will add these paths to Django: 95 | 96 | * ``/oauth2/login`` where users are redirected to, to initiate the login with ADFS. 97 | * ``/oauth2/login_no_sso`` where users are redirected to, to initiate the login with ADFS but forcing a login screen. 98 | * ``/oauth2/callback`` where ADFS redirects back to after login. So make sure you set the redirect URI on ADFS to this. 99 | * ``/oauth2/logout`` which logs out the user from both Django and ADFS. 100 | 101 | Below is sample Django template code to use these paths depending if 102 | you'd like to use GET or POST requests. Logging out was deprecated in 103 | `Django 4.1 `_. 104 | 105 | - Using GET requests: 106 | 107 | .. code-block:: html 108 | 109 | Logout 110 | Login 111 | Login (no SSO) 112 | 113 | - Using POST requests: 114 | 115 | .. code-block:: html+django 116 | 117 |
118 | {% csrf_token %} 119 | 120 |
121 |
122 | {% csrf_token %} 123 | 124 | 125 |
126 |
127 | {% csrf_token %} 128 | 129 | 130 |
131 | 132 | Contributing 133 | ------------ 134 | Contributions to the code are more then welcome. 135 | For more details have a look at the ``CONTRIBUTING.rst`` file. 136 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | dir = File.expand_path("..", __FILE__) 2 | 3 | Vagrant.configure("2") do |config| 4 | config.vagrant.plugins = "vagrant-reload" 5 | 6 | config.vm.define "adfs", autostart: false do |adfs| 7 | adfs.vm.hostname = "adfs" 8 | adfs.vm.box = "StefanScherer/windows_2019" 9 | 10 | adfs.vm.provider "virtualbox" do |v| 11 | v.memory = 2048 12 | v.gui = true 13 | v.customize ["modifyvm", :id, "--clipboard", "bidirectional"] 14 | end 15 | 16 | # If you change this IP, also change the DNS server for the "web" VM. 17 | adfs.vm.network "private_network", ip: "10.10.10.2" 18 | 19 | # Some winrm hacking 20 | # It prevents the connection with the VM from dropping 21 | # after promoting it to a domain controller 22 | adfs.winrm.timeout = 180 23 | adfs.winrm.retry_limit = 20 24 | adfs.winrm.retry_delay = 10 25 | adfs.winrm.transport = :plaintext 26 | adfs.winrm.basic_auth_only = true 27 | 28 | # Setup the domain controller 29 | adfs.vm.provision "shell", privileged: false, path: File.join(dir, 'vagrant', '01-setup-domain.ps1') 30 | adfs.vm.provision :reload 31 | adfs.vm.provision "shell", privileged: false, path: File.join(dir, 'vagrant', '02-setup-vagrant-user.ps1') 32 | # Setup ADFS 33 | adfs.vm.provision "shell", privileged: false, path: File.join(dir, 'vagrant', '03-setup-adfs.ps1') 34 | adfs.vm.provision :reload 35 | # Configure ADFS for use with the example project 36 | adfs.vm.provision "shell", privileged: false, path: File.join(dir, 'vagrant', '04-example-adfs-config.ps1') 37 | end 38 | 39 | config.vm.define "web" do |web| 40 | web.vm.hostname = "web" 41 | web.vm.box = "debian/buster64" 42 | 43 | # If you change this IP, you also have to change it in the file 03-example-adfs-config.ps1 44 | web.vm.network "private_network", ip: "10.10.10.10" 45 | web.vm.network "forwarded_port", guest: 8000, host: 8000 46 | 47 | # Install all needed tools and migrate the 2 example django projects 48 | web.vm.provision "shell", privileged: true, inline: <<-SHELL 49 | set -x 50 | apt-get update 51 | apt-get install -y python3-pip 52 | # Install django-auth-adfs in editable mode 53 | pip3 install -e /vagrant 54 | # Install DRF to demo the API integration 55 | pip3 install djangorestframework django-filter 56 | # run migrate command for both example projects 57 | python3 /vagrant/demo/adfs/manage.py makemigrations polls 58 | python3 /vagrant/demo/adfs/manage.py migrate 59 | python3 /vagrant/demo/formsbased/manage.py makemigrations polls 60 | python3 /vagrant/demo/formsbased/manage.py migrate 61 | # Set fixed hosts entry to ADFS server 62 | echo "10.10.10.2 adfs.example.com" >> /etc/hosts 63 | SHELL 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /demo/adfs/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError as exc: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) from exc 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /demo/adfs/mysite/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/adfs/mysite/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for mysite project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.0.5. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.0/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = '72zx9=7byz54=z-@oyuv^h)nse=qljty65zj$nj*$z42j353sa' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = ['*'] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django_auth_adfs', 35 | 'polls', 36 | 'django.contrib.admin', 37 | 'django.contrib.auth', 38 | 'django.contrib.contenttypes', 39 | 'django.contrib.sessions', 40 | 'django.contrib.messages', 41 | 'django.contrib.staticfiles', 42 | 'rest_framework', 43 | ] 44 | 45 | MIDDLEWARE = [ 46 | 'django.middleware.security.SecurityMiddleware', 47 | 'django.contrib.sessions.middleware.SessionMiddleware', 48 | 'django.middleware.common.CommonMiddleware', 49 | 'django.middleware.csrf.CsrfViewMiddleware', 50 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 51 | 'django.contrib.messages.middleware.MessageMiddleware', 52 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 53 | ] 54 | 55 | ROOT_URLCONF = 'mysite.urls' 56 | 57 | TEMPLATES = [ 58 | { 59 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 60 | 'DIRS': [os.path.join(BASE_DIR, 'templates')], 61 | 'APP_DIRS': True, 62 | 'OPTIONS': { 63 | 'context_processors': [ 64 | 'django.template.context_processors.debug', 65 | 'django.template.context_processors.request', 66 | 'django.contrib.auth.context_processors.auth', 67 | 'django.contrib.messages.context_processors.messages', 68 | ], 69 | }, 70 | }, 71 | ] 72 | 73 | WSGI_APPLICATION = 'mysite.wsgi.application' 74 | 75 | 76 | # Database 77 | # https://docs.djangoproject.com/en/2.0/ref/settings/#databases 78 | 79 | DATABASES = { 80 | 'default': { 81 | 'ENGINE': 'django.db.backends.sqlite3', 82 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 83 | } 84 | } 85 | 86 | 87 | # Password validation 88 | # https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators 89 | 90 | AUTH_PASSWORD_VALIDATORS = [ 91 | { 92 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 93 | }, 94 | { 95 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 96 | }, 97 | { 98 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 99 | }, 100 | { 101 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 102 | }, 103 | ] 104 | 105 | AUTHENTICATION_BACKENDS = ( 106 | 'django_auth_adfs.backend.AdfsAuthCodeBackend', 107 | 'django_auth_adfs.backend.AdfsAccessTokenBackend', 108 | ) 109 | 110 | AUTH_ADFS = { 111 | "SERVER": "adfs.example.com", 112 | "CLIENT_ID": "487d8ff7-80a8-4f62-b926-c2852ab06e94", 113 | "RELYING_PARTY_ID": "web.example.com", 114 | # Make sure to read the documentation about the AUDIENCE setting 115 | # when you configured the identifier as a URL! 116 | "AUDIENCE": "microsoft:identityserver:web.example.com", 117 | "CA_BUNDLE": False, # <<<-- !!! DON'T DO THIS IN A PRODUCTION SETUP !!! 118 | "CLAIM_MAPPING": {"first_name": "given_name", 119 | "last_name": "family_name", 120 | "email": "email"}, 121 | # ^^ = Model field ^^ = Claim 122 | "GROUP_TO_FLAG_MAPPING": {"is_superuser": "django_admins"}, 123 | # ^^ = Model field ^^ = Group 124 | # "BOOLEAN_CLAIM_MAPPING": {"is_staff": "is_staff"}, 125 | "CONFIG_RELOAD_INTERVAL": 0.1, 126 | } 127 | 128 | # Internationalization 129 | # https://docs.djangoproject.com/en/2.0/topics/i18n/ 130 | 131 | LANGUAGE_CODE = 'en-us' 132 | 133 | TIME_ZONE = 'UTC' 134 | 135 | USE_I18N = True 136 | 137 | USE_L10N = True 138 | 139 | USE_TZ = True 140 | 141 | 142 | # Static files (CSS, JavaScript, Images) 143 | # https://docs.djangoproject.com/en/2.0/howto/static-files/ 144 | 145 | STATIC_URL = '/static/' 146 | STATIC_ROOT = os.path.join(BASE_DIR, "static") 147 | 148 | LOGIN_URL = "django_auth_adfs:login" 149 | LOGIN_REDIRECT_URL = "/" 150 | 151 | LOGGING = { 152 | 'version': 1, 153 | 'disable_existing_loggers': False, 154 | 'formatters': { 155 | 'verbose': { 156 | 'format': '%(levelname)s %(asctime)s %(name)s %(message)s' 157 | }, 158 | }, 159 | 'handlers': { 160 | 'console': { 161 | 'class': 'logging.StreamHandler', 162 | 'formatter': 'verbose' 163 | }, 164 | }, 165 | 'loggers': { 166 | 'django_auth_adfs': { 167 | 'handlers': ['console'], 168 | 'level': 'DEBUG', 169 | }, 170 | }, 171 | } 172 | REST_FRAMEWORK = { 173 | 'DEFAULT_PERMISSION_CLASSES': ( 174 | 'rest_framework.permissions.IsAuthenticatedOrReadOnly', 175 | ), 176 | 'DEFAULT_AUTHENTICATION_CLASSES': ( 177 | 'django_auth_adfs.rest_framework.AdfsAccessTokenAuthentication', 178 | 'rest_framework.authentication.SessionAuthentication', 179 | ) 180 | } 181 | -------------------------------------------------------------------------------- /demo/adfs/mysite/urls.py: -------------------------------------------------------------------------------- 1 | """mysite URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.0/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.conf import settings 17 | from django.conf.urls.static import static 18 | from django.contrib import admin 19 | from django.contrib.auth.decorators import login_required 20 | from django.urls import include, path 21 | from django.views.generic.base import TemplateView 22 | 23 | admin.site.login = login_required(admin.site.login) 24 | 25 | urlpatterns = [ 26 | path('', TemplateView.as_view(template_name='home.html'), name='home'), 27 | path('polls/', include('polls.urls')), 28 | path('api/', include('polls.api.urls')), 29 | 30 | path('admin/', admin.site.urls, name='admin'), 31 | 32 | # The default rest framework urls shouldn't be included 33 | # If we include them, we'll end up with the DRF login page, 34 | # instead of being redirected to the ADFS login page. 35 | # 36 | # path('api-auth/', include('rest_framework.urls')), 37 | # 38 | path('oauth2/', include('django_auth_adfs.urls')), 39 | # This overrides the DRF login page 40 | path('oauth2/', include('django_auth_adfs.drf_urls')), 41 | ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 42 | -------------------------------------------------------------------------------- /demo/adfs/mysite/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for mysite project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /demo/adfs/polls/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/adfs/polls/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Choice, Question 4 | 5 | 6 | class ChoiceInline(admin.TabularInline): 7 | model = Choice 8 | extra = 3 9 | 10 | 11 | class QuestionAdmin(admin.ModelAdmin): 12 | fieldsets = [ 13 | (None, {'fields': ['question_text']}), 14 | ('Date information', {'fields': ['pub_date'], 'classes': ['collapse']}), 15 | ] 16 | inlines = [ChoiceInline] 17 | list_display = ('question_text', 'pub_date', 'was_published_recently') 18 | list_filter = ['pub_date'] 19 | search_fields = ['question_text'] 20 | 21 | 22 | admin.site.register(Question, QuestionAdmin) 23 | -------------------------------------------------------------------------------- /demo/adfs/polls/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/adfs/polls/api/filters.py: -------------------------------------------------------------------------------- 1 | import django_filters 2 | from ..models import Choice, Question 3 | 4 | 5 | class QuestionFilter(django_filters.FilterSet): 6 | class Meta: 7 | model = Question 8 | fields = ['question_text', 'pub_date'] 9 | 10 | 11 | class ChoiceFilter(django_filters.FilterSet): 12 | class Meta: 13 | model = Choice 14 | fields = ['question', 'choice_text', 'votes'] 15 | -------------------------------------------------------------------------------- /demo/adfs/polls/api/serializers.py: -------------------------------------------------------------------------------- 1 | from ..models import Choice, Question 2 | import rest_framework.serializers as serializers 3 | 4 | 5 | class QuestionSerializer(serializers.ModelSerializer): 6 | class Meta: 7 | model = Question 8 | fields = ['id', 'question_text', 'pub_date'] 9 | 10 | 11 | class ChoiceSerializer(serializers.ModelSerializer): 12 | votes = serializers.IntegerField(read_only=True) 13 | 14 | class Meta: 15 | model = Choice 16 | fields = ['id', 'question', 'choice_text', 'votes'] 17 | -------------------------------------------------------------------------------- /demo/adfs/polls/api/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework import routers 2 | 3 | from . import views 4 | 5 | 6 | router = routers.DefaultRouter() 7 | 8 | router.register(r'questions', views.QuestionViewSet) 9 | router.register(r'choices', views.ChoiceViewSet) 10 | 11 | app_name = 'polls-api' 12 | urlpatterns = router.urls 13 | -------------------------------------------------------------------------------- /demo/adfs/polls/api/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework.viewsets import ModelViewSet 2 | from rest_framework.decorators import action 3 | from rest_framework.permissions import IsAuthenticated 4 | from rest_framework.response import Response 5 | 6 | from ..models import Question, Choice 7 | from .serializers import QuestionSerializer, ChoiceSerializer 8 | from .filters import QuestionFilter, ChoiceFilter 9 | 10 | 11 | class QuestionViewSet(ModelViewSet): 12 | queryset = Question.objects.all() 13 | serializer_class = QuestionSerializer 14 | filter_class = QuestionFilter 15 | 16 | 17 | class ChoiceViewSet(ModelViewSet): 18 | queryset = Choice.objects.all() 19 | serializer_class = ChoiceSerializer 20 | filter_class = ChoiceFilter 21 | 22 | @action(methods=["post"], detail=True, permission_classes=[IsAuthenticated]) 23 | def vote(self, request, pk=None): 24 | """ 25 | post: 26 | A description of the post method on the custom action. 27 | """ 28 | choice = self.get_object() 29 | choice.vote() 30 | serializer = self.get_serializer(choice) 31 | return Response(serializer.data) 32 | -------------------------------------------------------------------------------- /demo/adfs/polls/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class PollsConfig(AppConfig): 5 | name = 'polls' 6 | -------------------------------------------------------------------------------- /demo/adfs/polls/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2019-11-01 17:08 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Question', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('question_text', models.CharField(max_length=200)), 20 | ('pub_date', models.DateTimeField(auto_now_add=True, verbose_name='date published')), 21 | ], 22 | ), 23 | migrations.CreateModel( 24 | name='Choice', 25 | fields=[ 26 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 27 | ('choice_text', models.CharField(max_length=200)), 28 | ('votes', models.IntegerField(default=0)), 29 | ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='polls.Question')), 30 | ], 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /demo/adfs/polls/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/adfs/polls/models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.db import models 4 | from django.utils import timezone 5 | 6 | 7 | class Question(models.Model): 8 | question_text = models.CharField(max_length=200) 9 | pub_date = models.DateTimeField('date published', auto_now_add=True) 10 | 11 | def __str__(self): 12 | return self.question_text 13 | 14 | def was_published_recently(self): 15 | now = timezone.now() 16 | return now - datetime.timedelta(days=1) <= self.pub_date <= now 17 | was_published_recently.admin_order_field = 'pub_date' 18 | was_published_recently.boolean = True 19 | was_published_recently.short_description = 'Published recently?' 20 | 21 | 22 | class Choice(models.Model): 23 | question = models.ForeignKey(Question, on_delete=models.CASCADE) 24 | choice_text = models.CharField(max_length=200) 25 | votes = models.IntegerField(default=0) 26 | 27 | def __str__(self): 28 | return self.choice_text 29 | 30 | def vote(self): 31 | assert not self._state.adding, "You can't vote on an unsaved choice" 32 | self.refresh_from_db() 33 | self.votes += 1 34 | self.full_clean() 35 | self.save() 36 | -------------------------------------------------------------------------------- /demo/adfs/polls/templates/admin/base_site.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.html" %} 2 | 3 | {% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} 4 | 5 | {% block branding %} 6 |

Polls Administration

7 | {% endblock %} 8 | 9 | {% block nav-global %}{% endblock %} 10 | -------------------------------------------------------------------------------- /demo/adfs/polls/templates/polls/detail.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 |

{{ question.question_text }}

4 |
5 |
6 |
    7 | {% for choice in question.choice_set.all %} 8 |
  • {{ choice.choice_text }} {{ choice.votes }}
  • 9 | {% endfor %} 10 |
11 |
12 |
13 |
14 |
15 | Vote 16 |
17 |
18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /demo/adfs/polls/templates/polls/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 |

Available polls

4 | {% if latest_question_list %} 5 |
6 | {% for question in latest_question_list %} 7 | {{ question.question_text }} 8 | {% endfor %} 9 |
10 | {% else %} 11 |

No polls are available.

12 | {% endif %} 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /demo/adfs/polls/templates/polls/vote.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 |

{{ question.question_text }}

4 | 5 | {% if error_message %}

{{ error_message }}

{% endif %} 6 | 7 | 8 |
9 | {% csrf_token %} 10 | {% for choice in question.choice_set.all %} 11 | 12 |
13 | {% endfor %} 14 | 15 |
16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /demo/adfs/polls/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | app_name = 'polls' 6 | urlpatterns = [ 7 | path('', views.IndexView.as_view(), name='index'), 8 | path('/', views.DetailView.as_view(), name='detail'), 9 | path('/vote/', views.VoteView.as_view(), name='vote'), 10 | # path('/savevote/', views.savevote, name='savevote'), 11 | ] 12 | -------------------------------------------------------------------------------- /demo/adfs/polls/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.mixins import LoginRequiredMixin 2 | from django.http import HttpResponseRedirect 3 | from django.shortcuts import get_object_or_404, render 4 | from django.urls import reverse 5 | from django.utils import timezone 6 | from django.views import generic 7 | 8 | from .models import Choice, Question 9 | 10 | 11 | class IndexView(generic.ListView): 12 | template_name = 'polls/index.html' 13 | context_object_name = 'latest_question_list' 14 | 15 | def get_queryset(self): 16 | """ 17 | Return the last five published questions (not including those set to be 18 | published in the future). 19 | """ 20 | return Question.objects.filter( 21 | pub_date__lte=timezone.now() 22 | ).order_by('-pub_date')[:5] 23 | 24 | 25 | class DetailView(generic.DetailView): 26 | model = Question 27 | template_name = 'polls/detail.html' 28 | 29 | def get_queryset(self): 30 | """ 31 | Excludes any questions that aren't published yet. 32 | """ 33 | return Question.objects.filter(pub_date__lte=timezone.now()) 34 | 35 | 36 | class VoteView(LoginRequiredMixin, generic.DetailView): 37 | model = Question 38 | template_name = 'polls/vote.html' 39 | 40 | def get_queryset(self): 41 | """ 42 | Excludes any questions that aren't published yet. 43 | """ 44 | return Question.objects.filter(pub_date__lte=timezone.now()) 45 | 46 | def post(self, request, pk, *args, **kwargs): 47 | question = get_object_or_404(Question, pk=pk) 48 | try: 49 | selected_choice = question.choice_set.get(pk=request.POST['choice']) 50 | except (KeyError, Choice.DoesNotExist): 51 | # Redisplay the question voting form. 52 | return render(request, 'polls/vote.html', { 53 | 'question': question, 54 | 'error_message': "You didn't select a choice.", 55 | }) 56 | else: 57 | selected_choice.vote() 58 | # Always return an HttpResponseRedirect after successfully dealing 59 | # with POST data. This prevents data from being posted twice if a 60 | # user hits the Back button. 61 | return HttpResponseRedirect(reverse('polls:detail', args=(question.id,))) 62 | -------------------------------------------------------------------------------- /demo/adfs/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {% block title %}Polls App{% endblock %} 10 | 11 | 12 | 13 | 14 |
15 | 40 |
41 |
42 | {% block content %}{% endblock %} 43 |
44 |
45 |
46 |
47 |
48 |
49 | Current User Info 50 |
51 |
52 |
53 | id         = {{ user.id }}
54 | username   = {{ user.username }}
55 | first_name = {{ user.first_name }}
56 | last_name  = {{ user.last_name }}
57 | email      = {{ user.email }}
58 | 
59 |
60 | is_authenticated = {{ user.is_authenticated }}
61 | is_staff         = {{ user.is_staff }}
62 | is_active        = {{ user.is_active }}
63 | is_superuser     = {{ user.is_superuser }}
64 | 
65 |
66 | last_login  = {{ user.last_login }}
67 | date_joined = {{ user.date_joined }}
68 | 
69 |
70 |
71 |
72 |
73 |
74 | 75 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /demo/adfs/templates/home.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %}Home{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Welcome to the polls app

9 |

Use the menu above to navigate

10 |
11 |
12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /demo/formsbased/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError as exc: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) from exc 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /demo/formsbased/mysite/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/formsbased/mysite/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for mysite project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.0.5. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.0/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = '72zx9=7byz54=z-@oyuv^h)nse=qljty65zj$nj*$z42j353sa' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = ['*'] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'polls', 35 | 'django.contrib.admin', 36 | 'django.contrib.auth', 37 | 'django.contrib.contenttypes', 38 | 'django.contrib.sessions', 39 | 'django.contrib.messages', 40 | 'django.contrib.staticfiles', 41 | 'rest_framework', 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | 'django.middleware.security.SecurityMiddleware', 46 | 'django.contrib.sessions.middleware.SessionMiddleware', 47 | 'django.middleware.common.CommonMiddleware', 48 | 'django.middleware.csrf.CsrfViewMiddleware', 49 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 50 | 'django.contrib.messages.middleware.MessageMiddleware', 51 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 52 | ] 53 | 54 | ROOT_URLCONF = 'mysite.urls' 55 | 56 | TEMPLATES = [ 57 | { 58 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 59 | 'DIRS': [os.path.join(BASE_DIR, 'templates')], 60 | 'APP_DIRS': True, 61 | 'OPTIONS': { 62 | 'context_processors': [ 63 | 'django.template.context_processors.debug', 64 | 'django.template.context_processors.request', 65 | 'django.contrib.auth.context_processors.auth', 66 | 'django.contrib.messages.context_processors.messages', 67 | ], 68 | }, 69 | }, 70 | ] 71 | 72 | WSGI_APPLICATION = 'mysite.wsgi.application' 73 | 74 | 75 | # Database 76 | # https://docs.djangoproject.com/en/2.0/ref/settings/#databases 77 | 78 | DATABASES = { 79 | 'default': { 80 | 'ENGINE': 'django.db.backends.sqlite3', 81 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 82 | } 83 | } 84 | 85 | 86 | # Password validation 87 | # https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators 88 | 89 | AUTH_PASSWORD_VALIDATORS = [ 90 | { 91 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 92 | }, 93 | { 94 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 95 | }, 96 | { 97 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 98 | }, 99 | { 100 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 101 | }, 102 | ] 103 | 104 | 105 | # Internationalization 106 | # https://docs.djangoproject.com/en/2.0/topics/i18n/ 107 | 108 | LANGUAGE_CODE = 'en-us' 109 | 110 | TIME_ZONE = 'UTC' 111 | 112 | USE_I18N = True 113 | 114 | USE_L10N = True 115 | 116 | USE_TZ = True 117 | 118 | 119 | # Static files (CSS, JavaScript, Images) 120 | # https://docs.djangoproject.com/en/2.0/howto/static-files/ 121 | 122 | STATIC_URL = '/static/' 123 | STATIC_ROOT = os.path.join(BASE_DIR, "static") 124 | 125 | LOGIN_REDIRECT_URL = "home" 126 | 127 | REST_FRAMEWORK = { 128 | 'DEFAULT_PERMISSION_CLASSES': ( 129 | 'rest_framework.permissions.IsAuthenticatedOrReadOnly', 130 | ), 131 | 'DEFAULT_AUTHENTICATION_CLASSES': ( 132 | 'rest_framework.authentication.SessionAuthentication', 133 | ) 134 | } 135 | -------------------------------------------------------------------------------- /demo/formsbased/mysite/urls.py: -------------------------------------------------------------------------------- 1 | """mysite URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.0/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.conf import settings 17 | from django.conf.urls.static import static 18 | from django.contrib import admin 19 | from django.contrib.auth import views as auth_views 20 | from django.urls import include, path 21 | from django.views.generic.base import TemplateView 22 | 23 | 24 | urlpatterns = [ 25 | path('', TemplateView.as_view(template_name='home.html'), name='home'), 26 | path('polls/', include('polls.urls')), 27 | path('api/', include('polls.api.urls')), 28 | path('api-auth/', include('rest_framework.urls')), 29 | path('admin/', admin.site.urls, name='admin'), 30 | path('accounts/login/', auth_views.LoginView.as_view(), name='login'), 31 | path('accounts/logout/', auth_views.LogoutView.as_view(), name='logout'), 32 | ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 33 | -------------------------------------------------------------------------------- /demo/formsbased/mysite/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for mysite project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /demo/formsbased/polls/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/formsbased/polls/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Choice, Question 4 | 5 | 6 | class ChoiceInline(admin.TabularInline): 7 | model = Choice 8 | extra = 3 9 | 10 | 11 | class QuestionAdmin(admin.ModelAdmin): 12 | fieldsets = [ 13 | (None, {'fields': ['question_text']}), 14 | ('Date information', {'fields': ['pub_date'], 'classes': ['collapse']}), 15 | ] 16 | inlines = [ChoiceInline] 17 | list_display = ('question_text', 'pub_date', 'was_published_recently') 18 | list_filter = ['pub_date'] 19 | search_fields = ['question_text'] 20 | 21 | 22 | admin.site.register(Question, QuestionAdmin) 23 | -------------------------------------------------------------------------------- /demo/formsbased/polls/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/formsbased/polls/api/filters.py: -------------------------------------------------------------------------------- 1 | import django_filters 2 | from ..models import Choice, Question 3 | 4 | 5 | class QuestionFilter(django_filters.FilterSet): 6 | class Meta: 7 | model = Question 8 | fields = ['question_text', 'pub_date'] 9 | 10 | 11 | class ChoiceFilter(django_filters.FilterSet): 12 | class Meta: 13 | model = Choice 14 | fields = ['question', 'choice_text', 'votes'] 15 | -------------------------------------------------------------------------------- /demo/formsbased/polls/api/serializers.py: -------------------------------------------------------------------------------- 1 | from ..models import Choice, Question 2 | import rest_framework.serializers as serializers 3 | 4 | 5 | class QuestionSerializer(serializers.ModelSerializer): 6 | class Meta: 7 | model = Question 8 | fields = ['id', 'question_text', 'pub_date'] 9 | 10 | 11 | class ChoiceSerializer(serializers.ModelSerializer): 12 | votes = serializers.IntegerField(read_only=True) 13 | 14 | class Meta: 15 | model = Choice 16 | fields = ['id', 'question', 'choice_text', 'votes'] 17 | -------------------------------------------------------------------------------- /demo/formsbased/polls/api/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework import routers 2 | 3 | from . import views 4 | 5 | 6 | router = routers.DefaultRouter() 7 | 8 | router.register(r'questions', views.QuestionViewSet) 9 | router.register(r'choices', views.ChoiceViewSet) 10 | 11 | app_name = 'polls-api' 12 | urlpatterns = router.urls 13 | -------------------------------------------------------------------------------- /demo/formsbased/polls/api/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework.viewsets import ModelViewSet 2 | from rest_framework.decorators import action 3 | from rest_framework.permissions import IsAuthenticated 4 | from rest_framework.response import Response 5 | 6 | from ..models import Question, Choice 7 | from .serializers import QuestionSerializer, ChoiceSerializer 8 | from .filters import QuestionFilter, ChoiceFilter 9 | 10 | 11 | class QuestionViewSet(ModelViewSet): 12 | queryset = Question.objects.all() 13 | serializer_class = QuestionSerializer 14 | filter_class = QuestionFilter 15 | 16 | 17 | class ChoiceViewSet(ModelViewSet): 18 | queryset = Choice.objects.all() 19 | serializer_class = ChoiceSerializer 20 | filter_class = ChoiceFilter 21 | 22 | @action(methods=["post"], detail=True, permission_classes=[IsAuthenticated]) 23 | def vote(self, request, pk=None): 24 | """ 25 | post: 26 | A description of the post method on the custom action. 27 | """ 28 | choice = self.get_object() 29 | choice.vote() 30 | serializer = self.get_serializer(choice) 31 | return Response(serializer.data) 32 | -------------------------------------------------------------------------------- /demo/formsbased/polls/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class PollsConfig(AppConfig): 5 | name = 'polls' 6 | -------------------------------------------------------------------------------- /demo/formsbased/polls/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2019-11-01 17:08 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Question', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('question_text', models.CharField(max_length=200)), 20 | ('pub_date', models.DateTimeField(auto_now_add=True, verbose_name='date published')), 21 | ], 22 | ), 23 | migrations.CreateModel( 24 | name='Choice', 25 | fields=[ 26 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 27 | ('choice_text', models.CharField(max_length=200)), 28 | ('votes', models.IntegerField(default=0)), 29 | ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='polls.Question')), 30 | ], 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /demo/formsbased/polls/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/formsbased/polls/models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.db import models 4 | from django.utils import timezone 5 | 6 | 7 | class Question(models.Model): 8 | question_text = models.CharField(max_length=200) 9 | pub_date = models.DateTimeField('date published', auto_now_add=True) 10 | 11 | def __str__(self): 12 | return self.question_text 13 | 14 | def was_published_recently(self): 15 | now = timezone.now() 16 | return now - datetime.timedelta(days=1) <= self.pub_date <= now 17 | was_published_recently.admin_order_field = 'pub_date' 18 | was_published_recently.boolean = True 19 | was_published_recently.short_description = 'Published recently?' 20 | 21 | 22 | class Choice(models.Model): 23 | question = models.ForeignKey(Question, on_delete=models.CASCADE) 24 | choice_text = models.CharField(max_length=200) 25 | votes = models.IntegerField(default=0) 26 | 27 | def __str__(self): 28 | return self.choice_text 29 | 30 | def vote(self): 31 | assert not self._state.adding, "You can't vote on an unsaved choice" 32 | self.refresh_from_db() 33 | self.votes += 1 34 | self.full_clean() 35 | self.save() 36 | -------------------------------------------------------------------------------- /demo/formsbased/polls/templates/admin/base_site.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.html" %} 2 | 3 | {% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} 4 | 5 | {% block branding %} 6 |

Polls Administration

7 | {% endblock %} 8 | 9 | {% block nav-global %}{% endblock %} 10 | -------------------------------------------------------------------------------- /demo/formsbased/polls/templates/polls/detail.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 |

{{ question.question_text }}

4 |
5 |
6 |
    7 | {% for choice in question.choice_set.all %} 8 |
  • {{ choice.choice_text }} {{ choice.votes }}
  • 9 | {% endfor %} 10 |
11 |
12 |
13 |
14 |
15 | Vote 16 |
17 |
18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /demo/formsbased/polls/templates/polls/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 |

Available polls

4 | {% if latest_question_list %} 5 |
6 | {% for question in latest_question_list %} 7 | {{ question.question_text }} 8 | {% endfor %} 9 |
10 | {% else %} 11 |

No polls are available.

12 | {% endif %} 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /demo/formsbased/polls/templates/polls/vote.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 |

{{ question.question_text }}

4 | 5 | {% if error_message %}

{{ error_message }}

{% endif %} 6 | 7 | 8 |
9 | {% csrf_token %} 10 | {% for choice in question.choice_set.all %} 11 | 12 |
13 | {% endfor %} 14 | 15 |
16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /demo/formsbased/polls/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | app_name = 'polls' 6 | urlpatterns = [ 7 | path('', views.IndexView.as_view(), name='index'), 8 | path('/', views.DetailView.as_view(), name='detail'), 9 | path('/vote/', views.VoteView.as_view(), name='vote'), 10 | # path('/savevote/', views.savevote, name='savevote'), 11 | ] 12 | -------------------------------------------------------------------------------- /demo/formsbased/polls/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.mixins import LoginRequiredMixin 2 | from django.http import HttpResponseRedirect 3 | from django.shortcuts import get_object_or_404, render 4 | from django.urls import reverse 5 | from django.utils import timezone 6 | from django.views import generic 7 | 8 | from .models import Choice, Question 9 | 10 | 11 | class IndexView(generic.ListView): 12 | template_name = 'polls/index.html' 13 | context_object_name = 'latest_question_list' 14 | 15 | def get_queryset(self): 16 | """ 17 | Return the last five published questions (not including those set to be 18 | published in the future). 19 | """ 20 | return Question.objects.filter( 21 | pub_date__lte=timezone.now() 22 | ).order_by('-pub_date')[:5] 23 | 24 | 25 | class DetailView(generic.DetailView): 26 | model = Question 27 | template_name = 'polls/detail.html' 28 | 29 | def get_queryset(self): 30 | """ 31 | Excludes any questions that aren't published yet. 32 | """ 33 | return Question.objects.filter(pub_date__lte=timezone.now()) 34 | 35 | 36 | class VoteView(LoginRequiredMixin, generic.DetailView): 37 | model = Question 38 | template_name = 'polls/vote.html' 39 | 40 | def get_queryset(self): 41 | """ 42 | Excludes any questions that aren't published yet. 43 | """ 44 | return Question.objects.filter(pub_date__lte=timezone.now()) 45 | 46 | def post(self, request, pk, *args, **kwargs): 47 | question = get_object_or_404(Question, pk=pk) 48 | try: 49 | selected_choice = question.choice_set.get(pk=request.POST['choice']) 50 | except (KeyError, Choice.DoesNotExist): 51 | # Redisplay the question voting form. 52 | return render(request, 'polls/vote.html', { 53 | 'question': question, 54 | 'error_message': "You didn't select a choice.", 55 | }) 56 | else: 57 | selected_choice.vote() 58 | # Always return an HttpResponseRedirect after successfully dealing 59 | # with POST data. This prevents data from being posted twice if a 60 | # user hits the Back button. 61 | return HttpResponseRedirect(reverse('polls:detail', args=(question.id,))) 62 | -------------------------------------------------------------------------------- /demo/formsbased/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {% block title %}Polls App{% endblock %} 10 | 11 | 12 | 13 | 14 |
15 | 35 |
36 |
37 | {% block content %}{% endblock %} 38 |
39 |
40 |
41 |
42 |
43 |
44 | Current User Info 45 |
46 |
47 |
48 | id         = {{ user.id }}
49 | username   = {{ user.username }}
50 | first_name = {{ user.first_name }}
51 | last_name  = {{ user.last_name }}
52 | email      = {{ user.email }}
53 | 
54 |
55 | is_authenticated = {{ user.is_authenticated }}
56 | is_staff         = {{ user.is_staff }}
57 | is_active        = {{ user.is_active }}
58 | is_superuser     = {{ user.is_superuser }}
59 | 
60 |
61 | last_login  = {{ user.last_login }}
62 | date_joined = {{ user.date_joined }}
63 | 
64 |
65 |
66 |
67 |
68 |
69 | 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /demo/formsbased/templates/home.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %}Home{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Welcome to the polls app

9 |

Use the menu above to navigate

10 |
11 |
12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /demo/formsbased/templates/registration/logged_out.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %}See you!{% endblock %} 4 | 5 | {% block content %} 6 |

Logged out

7 |

You have been successfully logged out.

8 |

Log in again.

9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /demo/formsbased/templates/registration/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

Login

5 | {% if form.errors %} 6 |

Your username and password didn't match. Please try again.

7 | {% endif %} 8 | 9 | {% if next %} 10 | {% if user.is_authenticated %} 11 |

Your account doesn't have access to this page. To proceed, 12 | please login with an account that has access.

13 | {% else %} 14 |

Please login to see this page.

15 | {% endif %} 16 | {% endif %} 17 |
18 | {% csrf_token %} 19 |
20 |
21 | 22 |
23 |
24 |
25 |
26 | 27 |
28 |
29 |
30 |
31 | 32 |
33 |
34 | 35 |
36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /django_auth_adfs/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Don't put imports or code here 3 | This file is imported by setup.py 4 | Adding imports here will break setup.py 5 | """ 6 | 7 | __version__ = '1.15.1' 8 | -------------------------------------------------------------------------------- /django_auth_adfs/drf-urls.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | import warnings 3 | from .drf_urls import * 4 | 5 | warnings.warn( 6 | "drf-urls.py is not a valid module name and will be " 7 | "removed in a future version, use drf_urls.py instead", 8 | PendingDeprecationWarning 9 | ) 10 | -------------------------------------------------------------------------------- /django_auth_adfs/drf_urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | These URL patterns are used to override the default Django Rest Framework login page. 3 | 4 | It's a bit of a hack, but DRF doesn't support overriding the login URL. 5 | """ 6 | from django.urls import re_path 7 | 8 | from django_auth_adfs import views 9 | 10 | app_name = "rest_framework" 11 | 12 | urlpatterns = [ 13 | re_path(r'^login$', views.OAuth2LoginView.as_view(), name='login'), 14 | re_path(r'^logout$', views.OAuth2LogoutView.as_view(), name='logout'), 15 | ] 16 | -------------------------------------------------------------------------------- /django_auth_adfs/exceptions.py: -------------------------------------------------------------------------------- 1 | class MFARequired(Exception): 2 | """ 3 | Exception to indicate that a MFA auth is required. 4 | """ 5 | pass 6 | -------------------------------------------------------------------------------- /django_auth_adfs/middleware.py: -------------------------------------------------------------------------------- 1 | """ 2 | Based on https://djangosnippets.org/snippets/1179/ 3 | """ 4 | from re import compile 5 | 6 | from django.conf import settings as django_settings 7 | from django.contrib.auth.views import redirect_to_login 8 | from django.urls import reverse 9 | 10 | from django_auth_adfs.exceptions import MFARequired 11 | from django_auth_adfs.config import settings 12 | 13 | LOGIN_EXEMPT_URLS = [ 14 | compile(django_settings.LOGIN_URL.lstrip('/')), 15 | compile(reverse("django_auth_adfs:login").lstrip('/')), 16 | compile(reverse("django_auth_adfs:logout").lstrip('/')), 17 | compile(reverse("django_auth_adfs:callback").lstrip('/')), 18 | ] 19 | if hasattr(settings, 'LOGIN_EXEMPT_URLS'): 20 | LOGIN_EXEMPT_URLS += [compile(expr) for expr in settings.LOGIN_EXEMPT_URLS] 21 | 22 | 23 | class LoginRequiredMiddleware: 24 | """ 25 | Middleware that requires a user to be authenticated to view any page other 26 | than LOGIN_URL. Exemptions to this requirement can optionally be specified 27 | in settings via a list of regular expressions in LOGIN_EXEMPT_URLS (which 28 | you can copy from your urls.py). 29 | 30 | Requires authentication middleware and template context processors to be 31 | loaded. You'll get an error if they aren't. 32 | """ 33 | def __init__(self, get_response): 34 | self.get_response = get_response 35 | 36 | def __call__(self, request): 37 | assert hasattr(request, 'user'), "The Login Required middleware requires " \ 38 | "authentication middleware to be installed. " \ 39 | "Edit your MIDDLEWARE setting to insert " \ 40 | "'django.contrib.auth.middleware.AuthenticationMiddleware'. " \ 41 | "If that doesn't work, ensure your TEMPLATE_CONTEXT_PROCESSORS " \ 42 | "setting includes 'django.core.context_processors.auth'." 43 | if not request.user.is_authenticated: 44 | path = request.path_info.lstrip('/') 45 | if not any(m.match(path) for m in LOGIN_EXEMPT_URLS): 46 | try: 47 | return redirect_to_login(request.get_full_path()) 48 | except MFARequired: 49 | return redirect_to_login('django_auth_adfs:login-force-mfa') 50 | 51 | return self.get_response(request) 52 | -------------------------------------------------------------------------------- /django_auth_adfs/rest_framework.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from django.contrib.auth import authenticate 4 | from rest_framework import exceptions 5 | from rest_framework.authentication import ( 6 | BaseAuthentication, get_authorization_header 7 | ) 8 | 9 | from django_auth_adfs.exceptions import MFARequired 10 | 11 | 12 | class AdfsAccessTokenAuthentication(BaseAuthentication): 13 | """ 14 | ADFS access Token authentication 15 | """ 16 | www_authenticate_realm = 'api' 17 | 18 | def authenticate(self, request): 19 | """ 20 | Returns a `User` if a correct access token has been supplied 21 | in the Authorization header. Otherwise returns `None`. 22 | """ 23 | auth = get_authorization_header(request).split() 24 | 25 | if not auth or auth[0].lower() != b'bearer': 26 | return None 27 | 28 | if len(auth) == 1: 29 | msg = 'Invalid authorization header. No credentials provided.' 30 | raise exceptions.AuthenticationFailed(msg) 31 | elif len(auth) > 2: 32 | msg = 'Invalid authorization header. Access token should not contain spaces.' 33 | raise exceptions.AuthenticationFailed(msg) 34 | 35 | # Authenticate the user 36 | # The AdfsAuthCodeBackend authentication backend will notice the "access_token" parameter 37 | # and skip the request for an access token using the authorization code 38 | try: 39 | user = authenticate(access_token=auth[1]) 40 | except MFARequired as e: 41 | raise exceptions.AuthenticationFailed('MFA auth is required.') from e 42 | 43 | if user is None: 44 | raise exceptions.AuthenticationFailed('Invalid access token.') 45 | 46 | if not user.is_active: 47 | raise exceptions.AuthenticationFailed('User inactive or deleted.') 48 | 49 | return user, auth[1] 50 | 51 | def authenticate_header(self, request): 52 | return 'Bearer realm="%s" token_type="JWT"' % self.www_authenticate_realm 53 | -------------------------------------------------------------------------------- /django_auth_adfs/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | # Arguments sent with the signal: 4 | # * user 5 | # * claims 6 | # * adfs_response 7 | post_authenticate = Signal() 8 | -------------------------------------------------------------------------------- /django_auth_adfs/templates/django_auth_adfs/login_failed.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Login Failure 6 | 25 | 26 | 27 |
28 | {{ error_message }} 29 |
30 | 31 | 32 | -------------------------------------------------------------------------------- /django_auth_adfs/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | 3 | from django_auth_adfs import views 4 | 5 | app_name = "django_auth_adfs" 6 | 7 | urlpatterns = [ 8 | re_path(r'^callback$', views.OAuth2CallbackView.as_view(), name='callback'), 9 | re_path(r'^login$', views.OAuth2LoginView.as_view(), name='login'), 10 | re_path(r'^login_no_sso$', views.OAuth2LoginNoSSOView.as_view(), name='login-no-sso'), 11 | re_path(r'^login_force_mfa$', views.OAuth2LoginForceMFA.as_view(), name='login-force-mfa'), 12 | re_path(r'^logout$', views.OAuth2LogoutView.as_view(), name='logout'), 13 | ] 14 | -------------------------------------------------------------------------------- /django_auth_adfs/views.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import logging 3 | 4 | from django.conf import settings as django_settings 5 | from django.contrib.auth import authenticate, login, logout 6 | from django.shortcuts import redirect 7 | try: 8 | from django.utils.http import url_has_allowed_host_and_scheme 9 | except ImportError: 10 | # Django <3.0 11 | from django.utils.http import is_safe_url as url_has_allowed_host_and_scheme 12 | from django.views.generic import View 13 | 14 | from django_auth_adfs.config import provider_config, settings 15 | from django_auth_adfs.exceptions import MFARequired 16 | 17 | logger = logging.getLogger("django_auth_adfs") 18 | 19 | 20 | class OAuth2CallbackView(View): 21 | def get(self, request): 22 | """ 23 | Handles the redirect from ADFS to our site. 24 | We try to process the passed authorization code and login the user. 25 | 26 | Args: 27 | request (django.http.request.HttpRequest): A Django Request object 28 | """ 29 | code = request.GET.get("code") 30 | if not code: 31 | # Return an error message 32 | return settings.CUSTOM_FAILED_RESPONSE_VIEW( 33 | request, 34 | error_message="No authorization code was provided.", 35 | status=400 36 | ) 37 | 38 | redirect_to = request.GET.get("state") 39 | try: 40 | user = authenticate(request=request, authorization_code=code) 41 | except MFARequired: 42 | return redirect(provider_config.build_authorization_endpoint(request, force_mfa=True)) 43 | 44 | if user: 45 | if user.is_active: 46 | login(request, user) 47 | # Redirect to the "after login" page. 48 | # Because we got redirected from ADFS, we can't know where the 49 | # user came from. 50 | if redirect_to: 51 | redirect_to = base64.urlsafe_b64decode(redirect_to.encode()).decode() 52 | else: 53 | redirect_to = django_settings.LOGIN_REDIRECT_URL 54 | url_is_safe = url_has_allowed_host_and_scheme( 55 | url=redirect_to, 56 | allowed_hosts=[request.get_host()], 57 | require_https=request.is_secure(), 58 | ) 59 | redirect_to = redirect_to if url_is_safe else '/' 60 | return redirect(redirect_to) 61 | else: 62 | # Return a 'disabled account' error message 63 | return settings.CUSTOM_FAILED_RESPONSE_VIEW( 64 | request, 65 | error_message="Your account is disabled.", 66 | status=403 67 | ) 68 | else: 69 | # Return an 'invalid login' error message 70 | return settings.CUSTOM_FAILED_RESPONSE_VIEW( 71 | request, 72 | error_message="Login failed.", 73 | status=401 74 | ) 75 | 76 | 77 | class OAuth2LoginView(View): 78 | def get(self, request): 79 | """ 80 | Initiates the OAuth2 flow and redirect the user agent to ADFS 81 | 82 | Args: 83 | request (django.http.request.HttpRequest): A Django Request object 84 | """ 85 | return redirect(provider_config.build_authorization_endpoint(request)) 86 | 87 | def post(self, request): 88 | """ 89 | Initiates the OAuth2 flow and redirect the user agent to ADFS 90 | 91 | Args: 92 | request (django.http.request.HttpRequest): A Django Request object 93 | """ 94 | return redirect(provider_config.build_authorization_endpoint(request)) 95 | 96 | 97 | class OAuth2LoginNoSSOView(View): 98 | def get(self, request): 99 | """ 100 | Initiates the OAuth2 flow and redirect the user agent to ADFS 101 | 102 | Args: 103 | request (django.http.request.HttpRequest): A Django Request object 104 | """ 105 | return redirect(provider_config.build_authorization_endpoint(request, disable_sso=True)) 106 | 107 | def post(self, request): 108 | """ 109 | Initiates the OAuth2 flow and redirect the user agent to ADFS 110 | 111 | Args: 112 | request (django.http.request.HttpRequest): A Django Request object 113 | """ 114 | return redirect(provider_config.build_authorization_endpoint(request, disable_sso=True)) 115 | 116 | 117 | class OAuth2LoginForceMFA(View): 118 | def get(self, request): 119 | """ 120 | Initiates the OAuth2 flow and redirect the user agent to ADFS 121 | 122 | Args: 123 | request (django.http.request.HttpRequest): A Django Request object 124 | """ 125 | return redirect(provider_config.build_authorization_endpoint(request, force_mfa=True)) 126 | 127 | def post(self, request): 128 | """ 129 | Initiates the OAuth2 flow and redirect the user agent to ADFS 130 | 131 | Args: 132 | request (django.http.request.HttpRequest): A Django Request object 133 | """ 134 | return redirect(provider_config.build_authorization_endpoint(request, force_mfa=True)) 135 | 136 | 137 | class OAuth2LogoutView(View): 138 | def get(self, request): 139 | """ 140 | Logs out the user from both Django and ADFS 141 | 142 | Args: 143 | request (django.http.request.HttpRequest): A Django Request object 144 | """ 145 | logout(request) 146 | return redirect(provider_config.build_end_session_endpoint()) 147 | 148 | def post(self, request): 149 | """ 150 | Logs out the user from both Django and ADFS 151 | 152 | Args: 153 | request (django.http.request.HttpRequest): A Django Request object 154 | """ 155 | logout(request) 156 | return redirect(provider_config.build_end_session_endpoint()) 157 | -------------------------------------------------------------------------------- /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 23 | help: 24 | @echo "Please use \`make ' where is one of" 25 | @echo " html to make standalone HTML files" 26 | @echo " dirhtml to make HTML files named index.html in directories" 27 | @echo " singlehtml to make a single large HTML file" 28 | @echo " pickle to make pickle files" 29 | @echo " json to make JSON files" 30 | @echo " htmlhelp to make HTML files and a HTML help project" 31 | @echo " qthelp to make HTML files and a qthelp project" 32 | @echo " applehelp to make an Apple Help Book" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | @echo " coverage to run coverage check of the documentation (if enabled)" 49 | 50 | .PHONY: clean 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | .PHONY: html 55 | html: 56 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 57 | @echo 58 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 59 | 60 | .PHONY: dirhtml 61 | dirhtml: 62 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 63 | @echo 64 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 65 | 66 | .PHONY: singlehtml 67 | singlehtml: 68 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 69 | @echo 70 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 71 | 72 | .PHONY: pickle 73 | pickle: 74 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 75 | @echo 76 | @echo "Build finished; now you can process the pickle files." 77 | 78 | .PHONY: json 79 | json: 80 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 81 | @echo 82 | @echo "Build finished; now you can process the JSON files." 83 | 84 | .PHONY: htmlhelp 85 | htmlhelp: 86 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 87 | @echo 88 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 89 | ".hhp project file in $(BUILDDIR)/htmlhelp." 90 | 91 | .PHONY: qthelp 92 | qthelp: 93 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 94 | @echo 95 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 96 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 97 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-auth-adfs.qhcp" 98 | @echo "To view the help file:" 99 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-auth-adfs.qhc" 100 | 101 | .PHONY: applehelp 102 | applehelp: 103 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 104 | @echo 105 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 106 | @echo "N.B. You won't be able to view it unless you put it in" \ 107 | "~/Library/Documentation/Help or install it in your application" \ 108 | "bundle." 109 | 110 | .PHONY: devhelp 111 | devhelp: 112 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 113 | @echo 114 | @echo "Build finished." 115 | @echo "To view the help file:" 116 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-auth-adfs" 117 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-auth-adfs" 118 | @echo "# devhelp" 119 | 120 | .PHONY: epub 121 | epub: 122 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 123 | @echo 124 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 125 | 126 | .PHONY: latex 127 | latex: 128 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 129 | @echo 130 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 131 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 132 | "(use \`make latexpdf' here to do that automatically)." 133 | 134 | .PHONY: latexpdf 135 | latexpdf: 136 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 137 | @echo "Running LaTeX files through pdflatex..." 138 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 139 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 140 | 141 | .PHONY: latexpdfja 142 | latexpdfja: 143 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 144 | @echo "Running LaTeX files through platex and dvipdfmx..." 145 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 146 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 147 | 148 | .PHONY: text 149 | text: 150 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 151 | @echo 152 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 153 | 154 | .PHONY: man 155 | man: 156 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 157 | @echo 158 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 159 | 160 | .PHONY: texinfo 161 | texinfo: 162 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 163 | @echo 164 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 165 | @echo "Run \`make' in that directory to run these through makeinfo" \ 166 | "(use \`make info' here to do that automatically)." 167 | 168 | .PHONY: info 169 | info: 170 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 171 | @echo "Running Texinfo files through makeinfo..." 172 | make -C $(BUILDDIR)/texinfo info 173 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 174 | 175 | .PHONY: gettext 176 | gettext: 177 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 178 | @echo 179 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 180 | 181 | .PHONY: changes 182 | changes: 183 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 184 | @echo 185 | @echo "The overview file is in $(BUILDDIR)/changes." 186 | 187 | .PHONY: linkcheck 188 | linkcheck: 189 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 190 | @echo 191 | @echo "Link check complete; look for any errors in the above output " \ 192 | "or in $(BUILDDIR)/linkcheck/output.txt." 193 | 194 | .PHONY: doctest 195 | doctest: 196 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 197 | @echo "Testing of doctests in the sources finished, look at the " \ 198 | "results in $(BUILDDIR)/doctest/output.txt." 199 | 200 | .PHONY: coverage 201 | coverage: 202 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 203 | @echo "Testing of coverage in the sources finished, look at the " \ 204 | "results in $(BUILDDIR)/coverage/python.txt." 205 | 206 | .PHONY: xml 207 | xml: 208 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 209 | @echo 210 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 211 | 212 | .PHONY: pseudoxml 213 | pseudoxml: 214 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 215 | @echo 216 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 217 | -------------------------------------------------------------------------------- /docs/_static/2012/01_add_relying_party.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2012/01_add_relying_party.png -------------------------------------------------------------------------------- /docs/_static/2012/02_add_relying_party_wizard_page1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2012/02_add_relying_party_wizard_page1.png -------------------------------------------------------------------------------- /docs/_static/2012/03_add_relying_party_wizard_page2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2012/03_add_relying_party_wizard_page2.png -------------------------------------------------------------------------------- /docs/_static/2012/04_add_relying_party_wizard_page3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2012/04_add_relying_party_wizard_page3.png -------------------------------------------------------------------------------- /docs/_static/2012/05_add_relying_party_wizard_page4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2012/05_add_relying_party_wizard_page4.png -------------------------------------------------------------------------------- /docs/_static/2012/06_add_relying_party_wizard_page5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2012/06_add_relying_party_wizard_page5.png -------------------------------------------------------------------------------- /docs/_static/2012/07_add_relying_party_wizard_page6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2012/07_add_relying_party_wizard_page6.png -------------------------------------------------------------------------------- /docs/_static/2012/08_relying_party_id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2012/08_relying_party_id.png -------------------------------------------------------------------------------- /docs/_static/2012/09_add_relying_party_wizard_page8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2012/09_add_relying_party_wizard_page8.png -------------------------------------------------------------------------------- /docs/_static/2012/10_add_relying_party_wizard_page9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2012/10_add_relying_party_wizard_page9.png -------------------------------------------------------------------------------- /docs/_static/2012/11_add_relying_party_wizard_review.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2012/11_add_relying_party_wizard_review.png -------------------------------------------------------------------------------- /docs/_static/2012/12_add_relying_party_wizard_page11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2012/12_add_relying_party_wizard_page11.png -------------------------------------------------------------------------------- /docs/_static/2012/13_configure_claims_page1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2012/13_configure_claims_page1.png -------------------------------------------------------------------------------- /docs/_static/2012/14_configure_claims_page2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2012/14_configure_claims_page2.png -------------------------------------------------------------------------------- /docs/_static/2012/15_configure_claims_page3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2012/15_configure_claims_page3.png -------------------------------------------------------------------------------- /docs/_static/2012/16_configure_claims_page4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2012/16_configure_claims_page4.png -------------------------------------------------------------------------------- /docs/_static/2016/01_add_app_group.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2016/01_add_app_group.png -------------------------------------------------------------------------------- /docs/_static/2016/02_add_app_group_wizard_page1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2016/02_add_app_group_wizard_page1.png -------------------------------------------------------------------------------- /docs/_static/2016/03_add_native_app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2016/03_add_native_app.png -------------------------------------------------------------------------------- /docs/_static/2016/04_native_app_access_policy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2016/04_native_app_access_policy.png -------------------------------------------------------------------------------- /docs/_static/2016/05_review_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2016/05_review_settings.png -------------------------------------------------------------------------------- /docs/_static/2016/06_wizard_end.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2016/06_wizard_end.png -------------------------------------------------------------------------------- /docs/_static/2016/07_app_group_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2016/07_app_group_settings.png -------------------------------------------------------------------------------- /docs/_static/2016/08_add_claim_rules.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2016/08_add_claim_rules.png -------------------------------------------------------------------------------- /docs/_static/2016/08_add_ldap_attributes_part1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2016/08_add_ldap_attributes_part1.png -------------------------------------------------------------------------------- /docs/_static/2016/08_add_ldap_attributes_part2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/2016/08_add_ldap_attributes_part2.png -------------------------------------------------------------------------------- /docs/_static/AzureAD/01-azure_active_directory.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/AzureAD/01-azure_active_directory.png -------------------------------------------------------------------------------- /docs/_static/AzureAD/02-azure_dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/AzureAD/02-azure_dashboard.png -------------------------------------------------------------------------------- /docs/_static/AzureAD/03-new_registrations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/AzureAD/03-new_registrations.png -------------------------------------------------------------------------------- /docs/_static/AzureAD/04-app_registrations_specs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/AzureAD/04-app_registrations_specs.png -------------------------------------------------------------------------------- /docs/_static/AzureAD/05-application_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/AzureAD/05-application_overview.png -------------------------------------------------------------------------------- /docs/_static/AzureAD/06-add_Secret.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/AzureAD/06-add_Secret.png -------------------------------------------------------------------------------- /docs/_static/AzureAD/07-add_Secret_name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/AzureAD/07-add_Secret_name.png -------------------------------------------------------------------------------- /docs/_static/AzureAD/08-copy_Secret.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/AzureAD/08-copy_Secret.png -------------------------------------------------------------------------------- /docs/_static/AzureAD/09_register_frontend_app.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/AzureAD/09_register_frontend_app.PNG -------------------------------------------------------------------------------- /docs/_static/AzureAD/10_copy-frontend-client_id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/AzureAD/10_copy-frontend-client_id.png -------------------------------------------------------------------------------- /docs/_static/AzureAD/11-navigate_to_expose_an_api.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/AzureAD/11-navigate_to_expose_an_api.PNG -------------------------------------------------------------------------------- /docs/_static/AzureAD/13_set_app_id.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/AzureAD/13_set_app_id.PNG -------------------------------------------------------------------------------- /docs/_static/AzureAD/14_add_a_scope.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/AzureAD/14_add_a_scope.PNG -------------------------------------------------------------------------------- /docs/_static/AzureAD/15_add_authorized_app_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/AzureAD/15_add_authorized_app_1.png -------------------------------------------------------------------------------- /docs/_static/AzureAD/16_add_authorized_app_2.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/AzureAD/16_add_authorized_app_2.PNG -------------------------------------------------------------------------------- /docs/_static/AzureAD/17_navigate_to_api_permissions.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/AzureAD/17_navigate_to_api_permissions.PNG -------------------------------------------------------------------------------- /docs/_static/AzureAD/18_add_permission.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/AzureAD/18_add_permission.PNG -------------------------------------------------------------------------------- /docs/_static/AzureAD/19_add-permission-2.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/AzureAD/19_add-permission-2.PNG -------------------------------------------------------------------------------- /docs/_static/AzureAD/20_add-permission-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/django-auth-adfs/HEAD/docs/_static/AzureAD/20_add-permission-3.png -------------------------------------------------------------------------------- /docs/_templates/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/adfs_3.0_config_guide.rst: -------------------------------------------------------------------------------- 1 | Windows 2012 R2 - ADFS 3.0 2 | ========================== 3 | 4 | Getting this module to work is sometimes not so straight forward. If your not familiar with JWT tokens or ADFS itself, 5 | it might take some tries to get all settings right. 6 | 7 | This guide tries to give a basic overview of how to configure ADFS and how to determine the settings for 8 | django-auth-adfs. Installing and configuring the basics of ADFS is not explained here. 9 | 10 | * **ADFS server:** https://adfs.example.com 11 | * **Web server:** http://web.example.com:8000 12 | 13 | Step 1 - Configuring a Relying Party Trust 14 | ------------------------------------------ 15 | 16 | From the AD FS Management screen, go to **AD FS ➜ Trust Relationships ➜ Relying Party Trusts** and 17 | click **Add Relying Party Trust...** 18 | 19 | .. image:: _static/2012/01_add_relying_party.png 20 | :scale: 50 % 21 | 22 | ------------ 23 | 24 | Click **Start** 25 | 26 | .. image:: _static/2012/02_add_relying_party_wizard_page1.png 27 | :scale: 50 % 28 | 29 | ------------ 30 | 31 | Select **Enter data about the relying party manually** and click **Next** 32 | 33 | .. image:: _static/2012/03_add_relying_party_wizard_page2.png 34 | :scale: 50 % 35 | 36 | ------------ 37 | 38 | Enter a display name for the relying party and click **Next**. 39 | 40 | .. image:: _static/2012/04_add_relying_party_wizard_page3.png 41 | :scale: 50 % 42 | 43 | ------------ 44 | 45 | Select **AD FS profile** and click **Next** 46 | 47 | .. image:: _static/2012/05_add_relying_party_wizard_page4.png 48 | :scale: 50 % 49 | 50 | ------------ 51 | 52 | Leave everything empty click **Next** 53 | 54 | .. image:: _static/2012/06_add_relying_party_wizard_page5.png 55 | :scale: 50 % 56 | 57 | ------------ 58 | 59 | We don't need WS-Federation or SAML support so leave everything empty and click **Next** 60 | 61 | .. image:: _static/2012/07_add_relying_party_wizard_page6.png 62 | :scale: 50 % 63 | 64 | ------------ 65 | 66 | Enter a relying party trust identifier and click **add**. The identifier can be anything but beware, there's a 67 | difference between entering a URL and something else. For more details see the example section of 68 | :ref:`the AUDIENCE setting `. 69 | 70 | .. note:: 71 | This is the value for the :ref:`audience_setting` and the :ref:`relying_party_id_setting` settings. 72 | 73 | .. image:: _static/2012/08_relying_party_id.png 74 | :scale: 50 % 75 | 76 | ------------ 77 | 78 | Select **I do not want to configure...** and click **Next**. 79 | 80 | .. image:: _static/2012/09_add_relying_party_wizard_page8.png 81 | :scale: 50 % 82 | 83 | ------------ 84 | 85 | Select **Permit all users to access the relying party** and click **Next**. 86 | 87 | .. image:: _static/2012/10_add_relying_party_wizard_page9.png 88 | :scale: 50 % 89 | 90 | ------------ 91 | 92 | Review the settings and click **Next** to create the relying party. 93 | 94 | .. image:: _static/2012/11_add_relying_party_wizard_review.png 95 | :scale: 50 % 96 | 97 | ------------ 98 | 99 | Check **Open the Edit Claim Rules dialog...** and click **Close** 100 | 101 | .. image:: _static/2012/12_add_relying_party_wizard_page11.png 102 | :scale: 50 % 103 | 104 | 105 | Step 2 - Configuring Claims 106 | --------------------------- 107 | 108 | If you selected **Open the Edit Claim Rules dialog...** while adding a relying party, this screen will open 109 | automatically. Else you can open it by right clicking the relying party in the list and select **Edit Claim Rules...** 110 | 111 | On the **Issuance Transform Rules** tab, click the **Add Rule** button 112 | 113 | .. image:: _static/2012/13_configure_claims_page1.png 114 | :scale: 50 % 115 | 116 | ------------ 117 | 118 | Select **Send LDAP Attributes as Claims** and click **Next** 119 | 120 | .. image:: _static/2012/14_configure_claims_page2.png 121 | :scale: 50 % 122 | 123 | ------------ 124 | 125 | Give the rule a name and select **Active Directory** as the attribute store. Then configure the below claims. 126 | 127 | +----------------------------------+----------------------+ 128 | | LDAP Attribute | Outgoing Claim Type | 129 | +==================================+======================+ 130 | | E-Mail-Addresses | E-Mail Address | 131 | +----------------------------------+----------------------+ 132 | | Given-Name | Given Name | 133 | +----------------------------------+----------------------+ 134 | | Surname | Surname | 135 | +----------------------------------+----------------------+ 136 | | Token-Groups - Unqualified Names | Group | 137 | +----------------------------------+----------------------+ 138 | | SAM-Account-Name | Windows Account Name | 139 | +----------------------------------+----------------------+ 140 | 141 | .. image:: _static/2012/15_configure_claims_page3.png 142 | :scale: 50 % 143 | 144 | Click **OK** to save the settings 145 | 146 | .. note:: 147 | The **Outgoing Claim Type** is what will be visible in the JWT Access Token. The first 3 claims will go into the 148 | :ref:`claim_mapping_setting` setting. The 4th is the :ref:`groups_claim_setting` setting. The 5th is the 149 | :ref:`username_claim_setting` setting. 150 | 151 | You cannot just copy the outgoing claim type value from this screen and use it in the settings. The name of the 152 | claim as it is in the JWT token is the short name which you can find in the AD FS Management screen underneath 153 | **AD FS ➜ Service ➜ Claim Descriptions** 154 | 155 | ------------ 156 | 157 | You should now see the rule added. Click **OK** to save the settings. 158 | 159 | .. image:: _static/2012/16_configure_claims_page4.png 160 | :scale: 50 % 161 | 162 | Step 3 - Add an ADFS client 163 | --------------------------- 164 | 165 | While the previous steps could be done via the GUI, the next step must be performed via PowerShell. 166 | 167 | Pick a value for the following fields. 168 | 169 | +-------------+----------------------------------------------+ 170 | | Name | Example value | 171 | +=============+==============================================+ 172 | | Name | Django Application OAuth2 Client | 173 | +-------------+----------------------------------------------+ 174 | | ClientId | 487d8ff7-80a8-4f62-b926-c2852ab06e94 | 175 | +-------------+----------------------------------------------+ 176 | | RedirectUri | http://web.example.com/oauth2/callback | 177 | +-------------+----------------------------------------------+ 178 | 179 | Now execute the following command from a powershell console. 180 | 181 | .. code-block:: ps1con 182 | 183 | PS C:\Users\Administrator> Add-ADFSClient -Name "Django Application OAuth2 Client" ` 184 | -ClientId "487d8ff7-80a8-4f62-b926-c2852ab06e94" ` 185 | -RedirectUri "http://web.example.com/oauth2/callback" 186 | 187 | The **ClientId** value will be the :ref:`client_id_setting` setting and the **RedirectUri** value is based on where you 188 | added the ```django_auth_adfs`` in your ``urls.py`` file. 189 | 190 | Step 4 - Determine configuration settings 191 | ----------------------------------------- 192 | 193 | Once everything is configured, you can use the below PowerShell commands to determine the value for the settings of this 194 | package. The ``<<<<<<`` in the output indicate which settings should match this value. 195 | 196 | .. code-block:: ps1con 197 | 198 | PS C:\Users\Administrator> Get-AdfsClient -Name "Django Application OAuth2 Client" 199 | 200 | RedirectUri : {http://web.example.com:8000/oauth2/callback} 201 | Name : Django Application OAuth2 Client 202 | Description : 203 | ClientId : 487d8ff7-80a8-4f62-b926-c2852ab06e94 <<< CLIENT_ID <<< 204 | BuiltIn : False 205 | Enabled : True 206 | ClientType : Public 207 | 208 | PS C:\Users\Administrator> Get-AdfsProperties | select HostName | Format-List 209 | 210 | HostName : adfs.example.com <<< SERVER <<< 211 | 212 | PS C:\Users\Administrator> Get-AdfsRelyingPartyTrust -Name "Django Application" | Select Identifier | Format-List 213 | 214 | Identifier : {web.example.com} <<< RELYING_PARTY_ID and AUDIENCE <<< 215 | 216 | If you followed this guide, you should end up with a configuration like this. 217 | 218 | .. code-block:: python 219 | 220 | AUTH_ADFS = { 221 | "SERVER": "adfs.example.com", 222 | "CLIENT_ID": "487d8ff7-80a8-4f62-b926-c2852ab06e94 ", 223 | "RELYING_PARTY_ID": "web.example.com", 224 | "AUDIENCE": "microsoft:identityserver:web.example.com", 225 | "CLAIM_MAPPING": {"first_name": "given_name", 226 | "last_name": "family_name", 227 | "email": "email"}, 228 | "USERNAME_CLAIM": "winaccountname", 229 | "GROUP_CLAIM": "group" 230 | } 231 | 232 | Enabling SSO for other browsers 233 | ------------------------------- 234 | 235 | By default, ADFS only supports seamless single sign-on for Internet Explorer. 236 | In other browsers, users will always be prompted for their username and password. 237 | 238 | To enable SSO also for other browsers like Chrome and Firefox, execute the following PowerShell command: 239 | 240 | .. code-block:: powershell 241 | 242 | [System.Collections.ArrayList]$UserAgents = Get-AdfsProperties | select -ExpandProperty WIASupportedUserAgents 243 | $UserAgents.Add("Mozilla/5.0") 244 | Set-ADFSProperties -WIASupportedUserAgents $UserAgents 245 | 246 | After that, restart the ADFS service on every server in the ADFS farm. 247 | 248 | For firefox, you'll also have to change it's ``network.automatic-ntlm-auth.trusted-uris`` setting 249 | to include the URI of your ADFS server. 250 | -------------------------------------------------------------------------------- /docs/adfs_4.0_config_guide.rst: -------------------------------------------------------------------------------- 1 | Windows 2016 - ADFS 4.0 2 | ======================= 3 | 4 | Getting this module to work is sometimes not so straight forward. If your not familiar with JWT tokens or ADFS itself, 5 | it might take some tries to get all settings right. 6 | 7 | This guide tries to give a basic overview of how to configure ADFS and how to determine the settings for 8 | django-auth-adfs. Installing and configuring the basics of ADFS is not explained here. 9 | 10 | * **ADFS server:** https://adfs.example.com 11 | * **Web server:** http://web.example.com:8000 12 | 13 | Step 1 - Configuring an Application Group 14 | ----------------------------------------- 15 | 16 | From the AD FS Management screen, go to **AD FS ➜ Application Groups** and 17 | click **Add Application Group...** 18 | 19 | .. image:: _static/2016/01_add_app_group.png 20 | :scale: 50 % 21 | 22 | ------------ 23 | 24 | Fill in a **name** for the application group, select **Web browser accessing a web application** and click **Next**. 25 | 26 | .. image:: _static/2016/02_add_app_group_wizard_page1.png 27 | :scale: 50 % 28 | 29 | ------------ 30 | 31 | Make note of the **Client Identifier** value. This will be the value for the :ref:`client_id_setting` setting. 32 | 33 | The **Redirect URI** value must match with the domain where your Django application is located and the patch where you 34 | mapped the ``django_auth_adfs`` urls in your ``urls.py`` file. If you follow the installation steps from this 35 | documentation, this should be something like ``https://your.domain.com/oauth2/callback``. 36 | 37 | .. image:: _static/2016/03_add_native_app.png 38 | :scale: 50 % 39 | 40 | ------------ 41 | 42 | Select **Permit everyone** and click **Next**. 43 | 44 | .. image:: _static/2016/04_native_app_access_policy.png 45 | :scale: 50 % 46 | 47 | ------------ 48 | 49 | Review the settings and click **Next** 50 | 51 | * The **Client ID** is the value for the :ref:`client_id_setting` setting. 52 | * The **Relying Party ID** is the value for the :ref:`relying_party_id_setting` and :ref:`audience_setting` setting. 53 | 54 | While they both are the same in this screenshot, they can be changed independently from one another afterwards. 55 | 56 | .. image:: _static/2016/05_review_settings.png 57 | :scale: 50 % 58 | 59 | ------------ 60 | 61 | Close the wizard by clicking **Close**. Our django application is now registered in ADFS. 62 | 63 | .. image:: _static/2016/06_wizard_end.png 64 | :scale: 50 % 65 | 66 | Step 2 - Configuring Claims 67 | --------------------------- 68 | 69 | Open the **properties** for the application group we just created. 70 | Select the **Web application** entry and click **Edit** 71 | 72 | .. image:: _static/2016/07_app_group_settings.png 73 | :scale: 50 % 74 | 75 | ------------ 76 | 77 | On the **Issuance Transform Rules** tab, click the **Add Rule** button 78 | 79 | .. image:: _static/2016/08_add_claim_rules.png 80 | :scale: 50 % 81 | 82 | ------------ 83 | 84 | Select **Send LDAP Attributes as Claims** and click **Next** 85 | 86 | .. image:: _static/2016/08_add_ldap_attributes_part1.png 87 | :scale: 50 % 88 | 89 | ------------ 90 | 91 | Give the rule a name and select **Active Directory** as the attribute store. Then configure the below claims. 92 | 93 | +----------------------------------+----------------------+ 94 | | LDAP Attribute | Outgoing Claim Type | 95 | +==================================+======================+ 96 | | E-Mail-Addresses | E-Mail Address | 97 | +----------------------------------+----------------------+ 98 | | Given-Name | Given Name | 99 | +----------------------------------+----------------------+ 100 | | Surname | Surname | 101 | +----------------------------------+----------------------+ 102 | | Token-Groups - Unqualified Names | Group | 103 | +----------------------------------+----------------------+ 104 | | SAM-Account-Name | Windows Account Name | 105 | +----------------------------------+----------------------+ 106 | 107 | .. image:: _static/2016/08_add_ldap_attributes_part2.png 108 | :scale: 50 % 109 | 110 | Click **Finish** to save the settings 111 | 112 | .. note:: 113 | The **Outgoing Claim Type** is what will be visible in the JWT Access Token. The first 3 claims will go into the 114 | :ref:`claim_mapping_setting` setting. The 4th is the :ref:`groups_claim_setting` setting. The 5th is the 115 | :ref:`username_claim_setting` setting. 116 | 117 | You cannot just copy the outgoing claim type value from this screen and use it in the settings. The name of the 118 | claim as it is in the JWT token is the short name which you can find in the AD FS Management screen underneath 119 | **AD FS ➜ Service ➜ Claim Descriptions** 120 | 121 | ------------ 122 | 123 | You should now see the rule added. Click **OK** a couple of times to save the settings. 124 | 125 | Step 3 - Determine configuration settings 126 | ----------------------------------------- 127 | 128 | Once everything is configured, you can use the below PowerShell commands to determine the value for the settings of this 129 | package. The ``<<<<<<`` in the output indicate which settings should match this value. 130 | 131 | .. code-block:: ps1con 132 | 133 | PS C:\Users\Administrator> Get-AdfsNativeClientApplication 134 | 135 | Name : Django Application - Native application 136 | Identifier : 487d8ff7-80a8-4f62-b926-c2852ab06e94 <<< CLIENT_ID <<< 137 | ApplicationGroupIdentifier : Django Application 138 | Description : 139 | Enabled : True 140 | RedirectUri : {http://web.example.com:8000/oauth2/callback} 141 | LogoutUri : 142 | 143 | PS C:\Users\Administrator> Get-AdfsProperties | select HostName | Format-List 144 | 145 | HostName : adfs.example.com <<< SERVER <<< 146 | 147 | PS C:\Users\Administrator> Get-AdfsWebApiApplication | select Identifier | Format-List 148 | 149 | Identifier : {web.example.com} <<< RELYING_PARTY_ID and AUDIENCE <<< 150 | 151 | If you followed this guide, you should end up with a configuration like this. 152 | 153 | .. code-block:: python 154 | 155 | AUTH_ADFS = { 156 | "SERVER": "adfs.example.com", 157 | "CLIENT_ID": "487d8ff7-80a8-4f62-b926-c2852ab06e94", 158 | "RELYING_PARTY_ID": "web.example.com", 159 | "AUDIENCE": "microsoft:identityserver:web.example.com", 160 | "CLAIM_MAPPING": {"first_name": "given_name", 161 | "last_name": "family_name", 162 | "email": "email"}, 163 | "USERNAME_CLAIM": "winaccountname", 164 | "GROUP_CLAIM": "group" 165 | } 166 | 167 | Enabling SSO for other browsers 168 | ------------------------------- 169 | 170 | By default, ADFS only supports seamless single sign-on for Internet Explorer. 171 | In other browsers, users will always be prompted for their username and password. 172 | 173 | To enable SSO also for other browsers like Chrome and Firefox, execute the following PowerShell command: 174 | 175 | .. code-block:: powershell 176 | 177 | [System.Collections.ArrayList]$UserAgents = Get-AdfsProperties | select -ExpandProperty WIASupportedUserAgents 178 | $UserAgents.Add("Mozilla/5.0") 179 | Set-ADFSProperties -WIASupportedUserAgents $UserAgents 180 | 181 | After that, restart the ADFS service on every server in the ADFS farm. 182 | 183 | For firefox, you'll also have to change it's ``network.automatic-ntlm-auth.trusted-uris`` setting 184 | to include the URI of your ADFS server. 185 | -------------------------------------------------------------------------------- /docs/azure_ad_config_guide.rst: -------------------------------------------------------------------------------- 1 | Azure AD 2 | ======== 3 | 4 | Getting this module to work is sometimes not so straightforward. If you're not familiar with JWT tokens or Azure AD 5 | itself, it might take some tries to get all the settings right. 6 | 7 | This guide tries to give a basic overview of how to configure Azure AD and how to determine the settings for 8 | django-auth-adfs. Installing and configuring the basics of Azure AD is not explained here. 9 | 10 | 11 | Step 1 - Register a backend application 12 | --------------------------------------- 13 | 14 | After signing in to `Azure `_. Open the **Azure Active Directory** dashboard. 15 | 16 | .. image:: _static/AzureAD/01-azure_active_directory.png 17 | :scale: 50 % 18 | 19 | ------------ 20 | 21 | 22 | Note down your **Tenant_ID** as you will need it later. 23 | 24 | 25 | .. image:: _static/AzureAD/02-azure_dashboard.png 26 | :scale: 50 % 27 | 28 | ------------ 29 | 30 | 31 | Navigate to **App Registrations**, then click **New registration** in the upper left hand corner. 32 | 33 | 34 | .. image:: _static/AzureAD/03-new_registrations.png 35 | :scale: 50 % 36 | 37 | ------------ 38 | 39 | 40 | Here you register your application. 41 | 42 | 1. The display name of your application. 43 | 2. What type of accounts can access your application. 44 | 3. Here you need to add allowed redirect URIs. The Redirect URI value must match with the domain where your Django application is located(*eg. http://localhost:8000/oauth2/callback*). 45 | 46 | 47 | .. image:: _static/AzureAD/04-app_registrations_specs.png 48 | :scale: 50 % 49 | 50 | ------------ 51 | 52 | 53 | When done registering, you will be redirected to your applications overview. Here you need to note down your **Client_ID**. This is how your Django project finds the right Azure application. 54 | 55 | 56 | .. image:: _static/AzureAD/05-application_overview.png 57 | :scale: 50 % 58 | 59 | ------------ 60 | 61 | 62 | Next we need to generate a **Client_Secret**. Your application will use this to prove its identity when requesting a token. 63 | 64 | 65 | .. image:: _static/AzureAD/06-add_Secret.png 66 | :scale: 50 % 67 | 68 | ------------ 69 | 70 | 71 | Give it a short (display) name. This is only used by you, to help keep track of in case you make more client secrets. 72 | 73 | 74 | .. image:: _static/AzureAD/07-add_Secret_name.png 75 | :scale: 50 % 76 | 77 | ------------ 78 | 79 | 80 | Copy your secret (value). It will be become hidden after a short time, so be sure to note this quickly. 81 | 82 | 83 | .. image:: _static/AzureAD/08-copy_Secret.png 84 | :scale: 50 % 85 | 86 | ------------ 87 | 88 | 89 | 90 | Step 2 - Configuring settings.py 91 | -------------------------------- 92 | We need to update the ``settings.py`` to accommodate our registered Azure AD application. 93 | 94 | Replace your AUTH_ADFS with this. 95 | 96 | .. code-block:: python 97 | 98 | # Client secret is not public information. Should store it as an environment variable. 99 | 100 | client_id = 'Your client id here' 101 | client_secret = 'Your client secret here' 102 | tenant_id = 'Your tenant id here' 103 | 104 | 105 | AUTH_ADFS = { 106 | 'AUDIENCE': [f'api://{client_id}', client_id], 107 | 'CLIENT_ID': client_id, 108 | 'CLIENT_SECRET': client_secret, 109 | 'CLAIM_MAPPING': { 110 | 'first_name': 'given_name', 111 | 'last_name': 'family_name', 112 | 'email': 'email' 113 | }, 114 | 'GROUPS_CLAIM': 'roles', 115 | 'MIRROR_GROUPS': True, 116 | 'USERNAME_CLAIM': 'upn', 117 | 'TENANT_ID': tenant_id, 118 | 'RELYING_PARTY_ID': client_id 119 | } 120 | 121 | 122 | 123 | Add this to your INSTALLED_APPS. 124 | 125 | .. code-block:: python 126 | 127 | INSTALLED_APPS = [ 128 | ... 129 | 'django_auth_adfs', 130 | ... 131 | ] 132 | 133 | 134 | 135 | Add this to your AUTHENTICATION_BACKENDS. 136 | 137 | .. code-block:: python 138 | 139 | AUTHENTICATION_BACKENDS = [ 140 | ... 141 | 'django_auth_adfs.backend.AdfsAccessTokenBackend', 142 | 'django_auth_adfs.backend.AdfsAuthCodeBackend' 143 | ... 144 | ] 145 | 146 | 147 | 148 | Add this path to your project's ``urls.py`` file. 149 | 150 | .. code-block:: python 151 | 152 | urlpatterns = [ 153 | ... 154 | path('oauth2/', include('django_auth_adfs.urls')), 155 | ... 156 | ] 157 | 158 | Step 3 - Register and configure an Azure AD frontend application 159 | ---------------------------------------------------------------- 160 | Just like we did with our backend application in step 1, we have to register a new app for our frontend. In this example we are authenticating a Django Rest Framework token through a single page application(SPA). The redirect URI value must match with the domain where your frontend application is located(eg. http://localhost:3000). 161 | 162 | 163 | 164 | 165 | .. image:: _static/AzureAD/09_register_frontend_app.PNG 166 | :scale: 50 % 167 | 168 | ------------ 169 | 170 | Copy your frontend's client ID, you will need later 171 | 172 | 173 | 174 | .. image:: _static/AzureAD/10_copy-frontend-client_id.png 175 | :scale: 50 % 176 | 177 | ------------ 178 | 179 | Now we need to add a scope of permissions to our API. 180 | Navigate back to app registrations and click on your backend application. 181 | Go to **Expose an API** in the sidebar and press **add a scope**. 182 | 183 | 184 | .. image:: _static/AzureAD/11-navigate_to_expose_an_api.PNG 185 | :scale: 50 % 186 | 187 | ------------ 188 | 189 | If you have not created an Application ID URI, it will be autogenerated for you. Select it and press **save and continue**. 190 | 191 | 192 | .. image:: _static/AzureAD/13_set_app_id.PNG 193 | :scale: 50 % 194 | 195 | ------------ 196 | 197 | Then we will create the actual scope. Call it "read", and just fill in all the required fields with "read" (maybe write an actual description). 198 | 199 | 200 | 201 | .. image:: _static/AzureAD/14_add_a_scope.PNG 202 | :scale: 50 % 203 | 204 | ------------ 205 | 206 | Now we are going to add our frontend application as a trusted app for our backend. Press **add a client application** 207 | 208 | 209 | .. image:: _static/AzureAD/15_add_authorized_app_1.png 210 | :scale: 50 % 211 | 212 | ------------ 213 | 214 | Here you need to paste in your frontend application (client) id. 215 | 216 | 217 | .. image:: _static/AzureAD/16_add_authorized_app_2.PNG 218 | :scale: 50 % 219 | 220 | ------------ 221 | 222 | Now navigate back to app registrations. Click on your **frontend** application and navigate to API permissions. Press **add a permission**. 223 | 224 | 225 | .. image:: _static/AzureAD/17_navigate_to_api_permissions.PNG 226 | :scale: 50 % 227 | 228 | ------------ 229 | 230 | Then we have to press **My API's** and then select the backend application. (This could be different if you don't have owner rights of the backend application.) 231 | 232 | 233 | .. image:: _static/AzureAD/18_add_permission.PNG 234 | :scale: 50 % 235 | 236 | ------------ 237 | 238 | Here we can give our frontend the permission scope we created earlier. Press **Delegated permissions** (should be default) and select the permission you created and press **add permission** 239 | 240 | 241 | 242 | .. image:: _static/AzureAD/19_add-permission-2.PNG 243 | :scale: 50 % 244 | 245 | ------------ 246 | 247 | Finally, sometimes the plugin will need to obtain the user groups claim from MS Graph (for example when the user has too many groups to fit in the access token), to ensure the plugin can do this successfully add the GroupMember.Read.All permission. 248 | 249 | 250 | .. image:: _static/AzureAD/20_add-permission-3.png 251 | :scale: 50 % 252 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # django_auth_adfs documentation build configuration file, created by 5 | # sphinx-quickstart on Fri Jan 29 11:23:45 2016. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | import sys 17 | import os 18 | import sphinx_rtd_theme 19 | 20 | # If extensions (or modules to document with autodoc) are in another directory, 21 | # add these directories to sys.path here. If the directory is relative to the 22 | # documentation root, use os.path.abspath to make it absolute, like shown here. 23 | sys.path.insert(0, os.path.abspath('../')) 24 | from django_auth_adfs import __version__ 25 | 26 | # -- General configuration ------------------------------------------------ 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | #needs_sphinx = '1.0' 30 | 31 | # Prevent non local immage warnings from showing 32 | suppress_warnings = ['image.nonlocal_uri'] 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be 35 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 36 | # ones. 37 | extensions = [ 38 | 'sphinx.ext.autodoc', 39 | 'sphinx.ext.napoleon' 40 | ] 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ['_templates'] 44 | 45 | # The suffix(es) of source filenames. 46 | # You can specify multiple suffix as a list of string: 47 | # source_suffix = ['.rst', '.md'] 48 | source_suffix = '.rst' 49 | 50 | # The encoding of source files. 51 | #source_encoding = 'utf-8-sig' 52 | 53 | # The master toctree document. 54 | master_doc = 'index' 55 | 56 | # General information about the project. 57 | project = 'django_auth_adfs' 58 | import datetime 59 | copyright = str(datetime.date.today().year) + ', Joris Beckers' 60 | author = 'Joris Beckers' 61 | 62 | # The version info for the project you're documenting, acts as replacement for 63 | # |version| and |release|, also used in various other places throughout the 64 | # built documents. 65 | # 66 | # The short X.Y version. 67 | version = __version__ 68 | # The full version, including alpha/beta/rc tags. 69 | release = __version__ 70 | 71 | # The language for content autogenerated by Sphinx. Refer to documentation 72 | # for a list of supported languages. 73 | # 74 | # This is also used if you do content translation via gettext catalogs. 75 | # Usually you set "language" from the command line for these cases. 76 | language = None 77 | 78 | # There are two options for replacing |today|: either, you set today to some 79 | # non-false value, then it is used: 80 | #today = '' 81 | # Else, today_fmt is used as the format for a strftime call. 82 | #today_fmt = '%B %d, %Y' 83 | 84 | # List of patterns, relative to source directory, that match files and 85 | # directories to ignore when looking for source files. 86 | exclude_patterns = ['_build'] 87 | 88 | # The reST default role (used for this markup: `text`) to use for all 89 | # documents. 90 | #default_role = None 91 | 92 | # If true, '()' will be appended to :func: etc. cross-reference text. 93 | #add_function_parentheses = True 94 | 95 | # If true, the current module name will be prepended to all description 96 | # unit titles (such as .. function::). 97 | #add_module_names = True 98 | 99 | # If true, sectionauthor and moduleauthor directives will be shown in the 100 | # output. They are ignored by default. 101 | #show_authors = False 102 | 103 | # The name of the Pygments (syntax highlighting) style to use. 104 | pygments_style = 'sphinx' 105 | 106 | # A list of ignored prefixes for module index sorting. 107 | #modindex_common_prefix = [] 108 | 109 | # If true, keep warnings as "system message" paragraphs in the built documents. 110 | #keep_warnings = False 111 | 112 | # If true, `todo` and `todoList` produce output, else they produce nothing. 113 | todo_include_todos = False 114 | 115 | 116 | # -- Options for HTML output ---------------------------------------------- 117 | 118 | # The theme to use for HTML and HTML Help pages. See the documentation for 119 | # a list of builtin themes. 120 | html_theme = 'sphinx_rtd_theme' 121 | 122 | # Theme options are theme-specific and customize the look and feel of a theme 123 | # further. For a list of options available for each theme, see the 124 | # documentation. 125 | #html_theme_options = {} 126 | 127 | # Add any paths that contain custom themes here, relative to this directory. 128 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 129 | 130 | # The name for this set of Sphinx documents. If None, it defaults to 131 | # " v documentation". 132 | #html_title = None 133 | 134 | # A shorter title for the navigation bar. Default is the same as html_title. 135 | #html_short_title = None 136 | 137 | # The name of an image file (relative to this directory) to place at the top 138 | # of the sidebar. 139 | #html_logo = None 140 | 141 | # The name of an image file (within the static path) to use as favicon of the 142 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 143 | # pixels large. 144 | #html_favicon = None 145 | 146 | # Add any paths that contain custom static files (such as style sheets) here, 147 | # relative to this directory. They are copied after the builtin static files, 148 | # so a file named "default.css" will overwrite the builtin "default.css". 149 | html_static_path = ['_static'] 150 | 151 | # Add any extra paths that contain custom files (such as robots.txt or 152 | # .htaccess) here, relative to this directory. These files are copied 153 | # directly to the root of the documentation. 154 | #html_extra_path = [] 155 | 156 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 157 | # using the given strftime format. 158 | #html_last_updated_fmt = '%b %d, %Y' 159 | 160 | # If true, SmartyPants will be used to convert quotes and dashes to 161 | # typographically correct entities. 162 | #html_use_smartypants = True 163 | 164 | # Custom sidebar templates, maps document names to template names. 165 | #html_sidebars = {} 166 | 167 | # Additional templates that should be rendered to pages, maps page names to 168 | # template names. 169 | #html_additional_pages = {} 170 | 171 | # If false, no module index is generated. 172 | #html_domain_indices = True 173 | 174 | # If false, no index is generated. 175 | #html_use_index = True 176 | 177 | # If true, the index is split into individual pages for each letter. 178 | #html_split_index = False 179 | 180 | # If true, links to the reST sources are added to the pages. 181 | #html_show_sourcelink = True 182 | 183 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 184 | #html_show_sphinx = True 185 | 186 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 187 | #html_show_copyright = True 188 | 189 | # If true, an OpenSearch description file will be output, and all pages will 190 | # contain a tag referring to it. The value of this option must be the 191 | # base URL from which the finished HTML is served. 192 | #html_use_opensearch = '' 193 | 194 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 195 | #html_file_suffix = None 196 | 197 | # Language to be used for generating the HTML full-text search index. 198 | # Sphinx supports the following languages: 199 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' 200 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr' 201 | #html_search_language = 'en' 202 | 203 | # A dictionary with options for the search language support, empty by default. 204 | # Now only 'ja' uses this config value 205 | #html_search_options = {'type': 'default'} 206 | 207 | # The name of a javascript file (relative to the configuration directory) that 208 | # implements a search results scorer. If empty, the default will be used. 209 | #html_search_scorer = 'scorer.js' 210 | 211 | # Output file base name for HTML help builder. 212 | htmlhelp_basename = 'django_auth_adfsdoc' 213 | 214 | # -- Options for LaTeX output --------------------------------------------- 215 | 216 | latex_elements = { 217 | # The paper size ('letterpaper' or 'a4paper'). 218 | #'papersize': 'letterpaper', 219 | 220 | # The font size ('10pt', '11pt' or '12pt'). 221 | #'pointsize': '10pt', 222 | 223 | # Additional stuff for the LaTeX preamble. 224 | #'preamble': '', 225 | 226 | # Latex figure (float) alignment 227 | #'figure_align': 'htbp', 228 | } 229 | 230 | # Grouping the document tree into LaTeX files. List of tuples 231 | # (source start file, target name, title, 232 | # author, documentclass [howto, manual, or own class]). 233 | latex_documents = [ 234 | (master_doc, 'django_auth_adfs.tex', 'django_auth_adfs Documentation', 235 | 'Joris Beckers', 'manual'), 236 | ] 237 | 238 | # The name of an image file (relative to this directory) to place at the top of 239 | # the title page. 240 | #latex_logo = None 241 | 242 | # For "manual" documents, if this is true, then toplevel headings are parts, 243 | # not chapters. 244 | #latex_use_parts = False 245 | 246 | # If true, show page references after internal links. 247 | #latex_show_pagerefs = False 248 | 249 | # If true, show URL addresses after external links. 250 | #latex_show_urls = False 251 | 252 | # Documents to append as an appendix to all manuals. 253 | #latex_appendices = [] 254 | 255 | # If false, no module index is generated. 256 | #latex_domain_indices = True 257 | 258 | 259 | # -- Options for manual page output --------------------------------------- 260 | 261 | # One entry per manual page. List of tuples 262 | # (source start file, name, description, authors, manual section). 263 | man_pages = [ 264 | (master_doc, 'django_auth_adfs', 'django_auth_adfs Documentation', 265 | [author], 1) 266 | ] 267 | 268 | # If true, show URL addresses after external links. 269 | #man_show_urls = False 270 | 271 | 272 | # -- Options for Texinfo output ------------------------------------------- 273 | 274 | # Grouping the document tree into Texinfo files. List of tuples 275 | # (source start file, target name, title, author, 276 | # dir menu entry, description, category) 277 | texinfo_documents = [ 278 | (master_doc, 'django_auth_adfs', 'django_auth_adfs Documentation', 279 | author, 'django_auth_adfs', 'One line description of project.', 280 | 'Miscellaneous'), 281 | ] 282 | 283 | # Documents to append as an appendix to all manuals. 284 | #texinfo_appendices = [] 285 | 286 | # If false, no module index is generated. 287 | #texinfo_domain_indices = True 288 | 289 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 290 | #texinfo_show_urls = 'footnote' 291 | 292 | # If true, do not generate a @detailmenu in the "Top" node's menu. 293 | #texinfo_no_detailmenu = False 294 | -------------------------------------------------------------------------------- /docs/config_guides.rst: -------------------------------------------------------------------------------- 1 | ADFS Config Guides 2 | ================== 3 | 4 | .. toctree:: 5 | :maxdepth: 3 6 | 7 | adfs_3.0_config_guide 8 | adfs_4.0_config_guide 9 | azure_ad_config_guide 10 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/demo.rst: -------------------------------------------------------------------------------- 1 | Demo 2 | ==== 3 | A ``Vagrantfile`` and example project are available to show what's needed to convert a Django project from form based 4 | authentication to ADFS authentication. 5 | 6 | Prerequisites 7 | ------------- 8 | * A hypervisor like `virtualbox `__. 9 | * A working `vagrant `__ installation. On Debian 11 (bullseye) if you use the `stock vagrant package `__ you need to install these plugins:: 10 | 11 | vagrant plugin install winrm 12 | vagrant plugin install winrm-fs 13 | vagrant plugin install winrm-elevated 14 | vagrant plugin install vagrant-reload 15 | 16 | * The github repository should be cloned/downloaded in some directory. 17 | 18 | This guide assumes you're using VirtualBox, but another hypervisor should also work. 19 | If you choose to use another one, make sure there's a windows server 2019 vagrant box available for it. 20 | 21 | Components 22 | ---------- 23 | The demo consists of 2 parts: 24 | 25 | * A web server VM. 26 | * A windows server 2019 VM. 27 | 28 | The webserver will run Django and is reachable at ``http://web.example.com:8000``. The windows server will run a 29 | domain controller and ADFS service. 30 | 31 | Starting the environment 32 | ------------------------ 33 | Web server 34 | ~~~~~~~~~~ 35 | First we get the web server up and running. 36 | 37 | #. Navigate to the directory where you cloned/downloaded the github repository. 38 | #. Bring up the web server by running the command:: 39 | 40 | vagrant up web 41 | 42 | #. Wait as the vagrant box is downloaded and the needed software installed. 43 | #. Next, SSH into the web server:: 44 | 45 | vagrant ssh web 46 | 47 | #. Once connected, start the Django project:: 48 | 49 | cd /vagrant/demo/adfs 50 | python3 manage.py runserver 0.0.0.0:8000 51 | 52 | you should now be able to browse the demo project by opening the page `http://localhost:8000 `__ 53 | in a browser. Pages requiring authentication wont work, because the ADFS server is not there yet. 54 | 55 | .. note:: 56 | 57 | There are 2 versions of the web example. One is a forms based authentication example, the other depends on ADFS. 58 | If you want to run the forms based example, change the path above to ``/vagrant/demo/formsbased`` 59 | 60 | ADFS server 61 | ~~~~~~~~~~~ 62 | The next vagrant box to start is the ADFS server. The scripts used for provisioning the ADFS server can be found in the 63 | folder ``/vagrant`` inside the repository. 64 | 65 | #. Navigate to the directory where you cloned/downloaded the github repository. 66 | #. Bring up the ADFS server by running the command:: 67 | 68 | vagrant up adfs 69 | 70 | #. Wait as the vagrant box is downloaded and the needed software installed. **For this windows box, it takes a couple 71 | of coffees before it's done.** 72 | #. Next, open window showing the login screen of the windows server. The login credentials are:: 73 | 74 | username: vagrant 75 | password: vagrant 76 | 77 | #. Once logged in, install a browser like Chrome of Firefox. 78 | #. Next, in that browser on the windows server, verify you can open the page 79 | `http://web.example.com:8000 `__ 80 | 81 | In the AD FS management console, you can check how the example project is configured. The config is in the 82 | **Application Groups** folder. 83 | 84 | .. note:: 85 | 86 | You wont be able to test the demo project from outside the windows machine because port 443 is not forwarded and 87 | name resolution of adfs.example.com won't work. You can workaround this by forwarding that port 443 from the guest 88 | to port 443 on your host and manually adding the right IP addresses in you hosts file. 89 | 90 | .. note:: 91 | 92 | Because windows server virtual boxes are rather rare on the vagrant cloud (they need to be rebuild every 180 days), 93 | it might be that the box specified in the ``Vagrantfile`` doesn't work anymore. If you replace it by another one 94 | that's just a vanilla windows server, it should work. 95 | 96 | Using the demo 97 | -------------- 98 | Once everything is up and running, you can click around in the very basic poll app that the demo is. 99 | 100 | * The bottom of the page shows details about the logged in user. 101 | * There are 2 users already created in the Active Directory domain. Both having the default password ``Password123`` 102 | 103 | * ``bob@example.com`` which is a Django super user because he's a member of active directory group ``django_admins``. 104 | * ``alice@example.com`` which is a regular Django user. 105 | 106 | * By default, only the page to vote on a poll requires you to be logged in. 107 | * There are no questions by default. Create some in the admin section with user ``bob``. 108 | * Compare the files in ``/vagrant/demo/formsbased`` to those in ``/vagrant/demo/adfs`` to see what was changed 109 | to enable ADFS authentication in a demo project. 110 | -------------------------------------------------------------------------------- /docs/faq.rst: -------------------------------------------------------------------------------- 1 | Frequently Asked Questions 2 | ========================== 3 | 4 | Why am I always redirected to ``/accounts/profile/`` after login? 5 | ----------------------------------------------------------------- 6 | This is default Django behaviour. You can change it by setting the Django setting named 7 | `LOGIN_REDIRECT_URL `_. 8 | 9 | How do I store additional info about a user? 10 | -------------------------------------------- 11 | ``django_auth_adfs`` can only store information in existing fields of the user model. 12 | If you want to store extra info, you'll have to extend the default user model with extra fields and adjust 13 | the :ref:`claim_mapping_setting` setting accordingly. 14 | 15 | `You can read about how to extend the user model here `_ 16 | 17 | I'm receiving an ``SSLError: CERTIFICATE_VERIFY_FAILED`` error. 18 | --------------------------------------------------------------- 19 | double check your ``CA_BUNDLE`` setting. Most likely your ADFS server is using a certificate signed by an 20 | enterprise root CA. you'll need to put it's certificate in a file and set ``CA_BUNDLE`` to it's path. 21 | 22 | I'm receiving an ``KeyError: 'upn'`` error when authenticating against Azure AD. 23 | -------------------------------------------------------------------------------- 24 | In some circumstances, Azure AD does not send the ``upn`` claim used to determine the username. It's observed to happen 25 | with guest users who's **source** in the users overview of Azure AD is ``Microsoft Account`` instead of 26 | ``Azure Active Directory``. 27 | 28 | In such cases, try setting the :ref:`username_claim_setting` to ``email`` instead of the default ``upn``. Or create a 29 | new user in your Azure AD directory. 30 | 31 | Why am I prompted for a username and password in Chrome/Firefox? 32 | ---------------------------------------------------------------- 33 | By default, ADFS only triggers seamless single sign-on for Internet Explorer or Edge. 34 | 35 | Have a look at the ADFS configuration guides for details about how to got this working 36 | for other browsers also. 37 | 38 | Why is a user added and removed from the same group on every login? 39 | ------------------------------------------------------------------- 40 | This can be caused by having a case insensitive database, such as a ``MySQL`` database with default settings. 41 | You can read more about `collation settings `_ 42 | in the official documentation. 43 | 44 | The redirect_uri starts with HTTP, while my site is HTTPS only. 45 | --------------------------------------------------------------- 46 | When you run Django behind a TLS terminating webserver or load balancer, then Django doesn't know the client arrived 47 | over a HTTPS connection. It will only see the plain HTTP traffic. Therefor, the link it generates and sends to ADFS 48 | as the ``redirect_uri`` query parameter, will start with HTTP, instead of HTTPS. 49 | 50 | To tell Django to generate HTTPS links, you need to set it's ``SECURE_PROXY_SSL_HEADER`` setting and inject the correct 51 | HTTP header and value on your web server. 52 | 53 | For more info, have a look at `Django's docs `_. 54 | 55 | I cannot get it working! 56 | ------------------------ 57 | Make sure you follow the instructions in the troubleshooting guide. 58 | It will enable debugging and can quickly tell you what is wrong. 59 | 60 | Also, walk through the :ref:`settings` once, you might find one 61 | that needs to be adjusted to match your situation. 62 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ADFS Authentication for Django 2 | ============================== 3 | 4 | .. image:: https://readthedocs.org/projects/django-auth-adfs/badge/?version=latest 5 | :target: http://django-auth-adfs.readthedocs.io/en/latest/?badge=latest 6 | :alt: Documentation Status 7 | .. image:: https://img.shields.io/pypi/v/django-auth-adfs.svg 8 | :target: https://pypi.python.org/pypi/django-auth-adfs 9 | .. image:: https://img.shields.io/pypi/pyversions/django-auth-adfs.svg 10 | :target: https://pypi.python.org/pypi/django-auth-adfs#downloads 11 | .. image:: https://img.shields.io/pypi/djversions/django-auth-adfs.svg 12 | :target: https://pypi.python.org/pypi/django-auth-adfs 13 | .. image:: https://codecov.io/github/snok/django-auth-adfs/coverage.svg?branch=main 14 | :target: https://codecov.io/github/snok/django-auth-adfs?branch=main 15 | 16 | A Django authentication backend for Microsoft ADFS and Azure AD 17 | 18 | * Free software: BSD License 19 | * Homepage: https://github.com/snok/django-auth-adfs 20 | * Documentation: http://django-auth-adfs.readthedocs.io/ 21 | 22 | Features 23 | -------- 24 | 25 | * Integrates Django with Active Directory on Windows 2012 R2, 2016 or Azure AD in the cloud. 26 | * Provides seamless single sign on (SSO) for your Django project on intranet environments. 27 | * Auto creates users and adds them to Django groups based on info received from ADFS. 28 | * Django Rest Framework (DRF) integration: Authenticate against your API with an ADFS access token. 29 | 30 | Contents 31 | -------- 32 | 33 | .. toctree:: 34 | :maxdepth: 3 35 | 36 | install 37 | oauth2_explained 38 | settings_ref 39 | config_guides 40 | middleware 41 | signals 42 | rest_framework 43 | demo 44 | troubleshooting 45 | faq 46 | contributing 47 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | .. _install: 2 | 3 | Installation 4 | ============ 5 | 6 | Requirements 7 | ------------ 8 | 9 | * Python 3.9 and above 10 | * Django 4.2 and above 11 | 12 | You will also need the following: 13 | 14 | * A properly configured Microsoft Windows server 2012 R2 or 2016 with the **AD FS** role installed 15 | or an Azure Active Directory setup. 16 | * A root CA bundle containing the root CA that signed the webserver certificate of your ADFS server if signed by an 17 | enterprise CA. 18 | 19 | .. note:: 20 | When using Azure AD, beware of the following limitations: 21 | 22 | * Users have no email address unless you assigned an Office 365 license to that user. 23 | * Groups are listed with their GUID in the groups claim. Meaning you have to create your groups in Django using 24 | these GUIDs, instead of their name. 25 | * Usernames are in the form of an email address, hence users created in Django follow this format. 26 | * You cannot send any custom claims, only those predefined by Azure AD. 27 | 28 | Package installation 29 | -------------------- 30 | 31 | Python package:: 32 | 33 | pip install django-auth-adfs 34 | 35 | Setting up django 36 | ----------------- 37 | 38 | In your project's ``settings.py`` add these settings. 39 | 40 | .. code-block:: python 41 | 42 | AUTHENTICATION_BACKENDS = ( 43 | ... 44 | 'django_auth_adfs.backend.AdfsAuthCodeBackend', 45 | ... 46 | ) 47 | 48 | INSTALLED_APPS = ( 49 | ... 50 | # Needed for the ADFS redirect URI to function 51 | 'django_auth_adfs', 52 | ... 53 | 54 | # checkout the documentation for more settings 55 | AUTH_ADFS = { 56 | "SERVER": "adfs.yourcompany.com", 57 | "CLIENT_ID": "your-configured-client-id", 58 | "RELYING_PARTY_ID": "your-adfs-RPT-name", 59 | # Make sure to read the documentation about the AUDIENCE setting 60 | # when you configured the identifier as a URL! 61 | "AUDIENCE": "microsoft:identityserver:your-RelyingPartyTrust-identifier", 62 | "CA_BUNDLE": "/path/to/ca-bundle.pem", 63 | "CLAIM_MAPPING": {"first_name": "given_name", 64 | "last_name": "family_name", 65 | "email": "email"}, 66 | } 67 | 68 | # Configure django to redirect users to the right URL for login 69 | LOGIN_URL = "django_auth_adfs:login" 70 | LOGIN_REDIRECT_URL = "/" 71 | 72 | ######################## 73 | # OPTIONAL SETTINGS 74 | ######################## 75 | 76 | MIDDLEWARE = ( 77 | ... 78 | # With this you can force a user to login without using 79 | # the LoginRequiredMixin on every view class 80 | # 81 | # You can specify URLs for which login is not enforced by 82 | # specifying them in the LOGIN_EXEMPT_URLS setting. 83 | 'django_auth_adfs.middleware.LoginRequiredMiddleware', 84 | ) 85 | 86 | # You can point login failures to a custom Django function based view for customization of the UI 87 | CUSTOM_FAILED_RESPONSE_VIEW = 'dot.path.to.custom.views.login_failed' 88 | 89 | In your project's ``urls.py`` add these paths: 90 | 91 | .. code-block:: python 92 | 93 | urlpatterns = [ 94 | ... 95 | path('oauth2/', include('django_auth_adfs.urls')), 96 | ] 97 | 98 | This will add these paths to Django: 99 | 100 | * ``/oauth2/login`` where users are redirected to, to initiate the login with ADFS. 101 | * ``/oauth2/login_no_sso`` where users are redirected to, to initiate the login with ADFS but forcing a login screen. 102 | * ``/oauth2/callback`` where ADFS redirects back to after login. So make sure you set the redirect URI on ADFS to this. 103 | * ``/oauth2/logout`` which logs out the user from both Django and ADFS. 104 | 105 | Below is sample Django template code to use these paths depending if 106 | you'd like to use GET or POST requests. Logging out was deprecated in 107 | `Django 4.1 `_. 108 | 109 | - Using GET requests: 110 | 111 | .. code-block:: html 112 | 113 | Logout 114 | Login 115 | Login (no SSO) 116 | 117 | - Using POST requests: 118 | 119 | .. code-block:: html+django 120 | 121 |
122 | {% csrf_token %} 123 | 124 |
125 |
126 | {% csrf_token %} 127 | 128 | 129 |
130 |
131 | {% csrf_token %} 132 | 133 | 134 |
135 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | echo. coverage to run coverage check of the documentation if enabled 41 | goto end 42 | ) 43 | 44 | if "%1" == "clean" ( 45 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 46 | del /q /s %BUILDDIR%\* 47 | goto end 48 | ) 49 | 50 | 51 | REM Check if sphinx-build is available and fallback to Python version if any 52 | %SPHINXBUILD% 1>NUL 2>NUL 53 | if errorlevel 9009 goto sphinx_python 54 | goto sphinx_ok 55 | 56 | :sphinx_python 57 | 58 | set SPHINXBUILD=python -m sphinx.__init__ 59 | %SPHINXBUILD% 2> nul 60 | if errorlevel 9009 ( 61 | echo. 62 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 63 | echo.installed, then set the SPHINXBUILD environment variable to point 64 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 65 | echo.may add the Sphinx directory to PATH. 66 | echo. 67 | echo.If you don't have Sphinx installed, grab it from 68 | echo.http://sphinx-doc.org/ 69 | exit /b 1 70 | ) 71 | 72 | :sphinx_ok 73 | 74 | 75 | if "%1" == "html" ( 76 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 77 | if errorlevel 1 exit /b 1 78 | echo. 79 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 80 | goto end 81 | ) 82 | 83 | if "%1" == "dirhtml" ( 84 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 85 | if errorlevel 1 exit /b 1 86 | echo. 87 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 88 | goto end 89 | ) 90 | 91 | if "%1" == "singlehtml" ( 92 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 93 | if errorlevel 1 exit /b 1 94 | echo. 95 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 96 | goto end 97 | ) 98 | 99 | if "%1" == "pickle" ( 100 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 101 | if errorlevel 1 exit /b 1 102 | echo. 103 | echo.Build finished; now you can process the pickle files. 104 | goto end 105 | ) 106 | 107 | if "%1" == "json" ( 108 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 109 | if errorlevel 1 exit /b 1 110 | echo. 111 | echo.Build finished; now you can process the JSON files. 112 | goto end 113 | ) 114 | 115 | if "%1" == "htmlhelp" ( 116 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 117 | if errorlevel 1 exit /b 1 118 | echo. 119 | echo.Build finished; now you can run HTML Help Workshop with the ^ 120 | .hhp project file in %BUILDDIR%/htmlhelp. 121 | goto end 122 | ) 123 | 124 | if "%1" == "qthelp" ( 125 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 129 | .qhcp project file in %BUILDDIR%/qthelp, like this: 130 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-auth-adfs.qhcp 131 | echo.To view the help file: 132 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-auth-adfs.ghc 133 | goto end 134 | ) 135 | 136 | if "%1" == "devhelp" ( 137 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 138 | if errorlevel 1 exit /b 1 139 | echo. 140 | echo.Build finished. 141 | goto end 142 | ) 143 | 144 | if "%1" == "epub" ( 145 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 146 | if errorlevel 1 exit /b 1 147 | echo. 148 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 149 | goto end 150 | ) 151 | 152 | if "%1" == "latex" ( 153 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 154 | if errorlevel 1 exit /b 1 155 | echo. 156 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 157 | goto end 158 | ) 159 | 160 | if "%1" == "latexpdf" ( 161 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 162 | cd %BUILDDIR%/latex 163 | make all-pdf 164 | cd %~dp0 165 | echo. 166 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 167 | goto end 168 | ) 169 | 170 | if "%1" == "latexpdfja" ( 171 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 172 | cd %BUILDDIR%/latex 173 | make all-pdf-ja 174 | cd %~dp0 175 | echo. 176 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 177 | goto end 178 | ) 179 | 180 | if "%1" == "text" ( 181 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 182 | if errorlevel 1 exit /b 1 183 | echo. 184 | echo.Build finished. The text files are in %BUILDDIR%/text. 185 | goto end 186 | ) 187 | 188 | if "%1" == "man" ( 189 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 190 | if errorlevel 1 exit /b 1 191 | echo. 192 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 193 | goto end 194 | ) 195 | 196 | if "%1" == "texinfo" ( 197 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 198 | if errorlevel 1 exit /b 1 199 | echo. 200 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 201 | goto end 202 | ) 203 | 204 | if "%1" == "gettext" ( 205 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 206 | if errorlevel 1 exit /b 1 207 | echo. 208 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 209 | goto end 210 | ) 211 | 212 | if "%1" == "changes" ( 213 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 214 | if errorlevel 1 exit /b 1 215 | echo. 216 | echo.The overview file is in %BUILDDIR%/changes. 217 | goto end 218 | ) 219 | 220 | if "%1" == "linkcheck" ( 221 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 222 | if errorlevel 1 exit /b 1 223 | echo. 224 | echo.Link check complete; look for any errors in the above output ^ 225 | or in %BUILDDIR%/linkcheck/output.txt. 226 | goto end 227 | ) 228 | 229 | if "%1" == "doctest" ( 230 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 231 | if errorlevel 1 exit /b 1 232 | echo. 233 | echo.Testing of doctests in the sources finished, look at the ^ 234 | results in %BUILDDIR%/doctest/output.txt. 235 | goto end 236 | ) 237 | 238 | if "%1" == "coverage" ( 239 | %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage 240 | if errorlevel 1 exit /b 1 241 | echo. 242 | echo.Testing of coverage in the sources finished, look at the ^ 243 | results in %BUILDDIR%/coverage/python.txt. 244 | goto end 245 | ) 246 | 247 | if "%1" == "xml" ( 248 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 249 | if errorlevel 1 exit /b 1 250 | echo. 251 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 252 | goto end 253 | ) 254 | 255 | if "%1" == "pseudoxml" ( 256 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 257 | if errorlevel 1 exit /b 1 258 | echo. 259 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 260 | goto end 261 | ) 262 | 263 | :end 264 | -------------------------------------------------------------------------------- /docs/middleware.rst: -------------------------------------------------------------------------------- 1 | Login Middleware 2 | ================ 3 | 4 | **django-auth-adfs** ships with a middleware class named ``LoginRequiredMiddleware``. 5 | You can use it to force an unauthenticated user to login and be redirected to the URL specified in in Django's 6 | ``LOGIN_URL`` setting without having to add code to every view. 7 | 8 | By default it's disabled for the page defined in the ``LOGIN_URL`` setting and the redirect page for ADFS. 9 | But by setting the ``LOGIN_EXEMPT_URLS`` setting, you can exclude other pages from authentication. 10 | Have a look at the :ref:`settings` for more information. 11 | 12 | To enable the middleware, add it to ``MIDDLEWARE`` in ``settings.py`` (or ``MIDDLEWARE_CLASSES`` if using Django <1.10. 13 | make sure to add it after any other session or authentication middleware to be sure all other methods of identifying 14 | the user are tried first. 15 | 16 | In your ``settings.py`` file, add the following: 17 | 18 | .. code-block:: python 19 | 20 | MIDDLEWARE = ( 21 | ... 22 | 'django_auth_adfs.middleware.LoginRequiredMiddleware', 23 | ) 24 | 25 | AUTH_ADFS = { 26 | ... 27 | "LOGIN_EXEMPT_URLS": ["api/", "public/"], 28 | ... 29 | } 30 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx_rtd_theme 2 | -------------------------------------------------------------------------------- /docs/rest_framework.rst: -------------------------------------------------------------------------------- 1 | Rest Framework integration 2 | ========================== 3 | 4 | Setup 5 | ----- 6 | 7 | When using Django Rest Framework, you can also use this package to authenticate 8 | your REST API clients. For this you need to do some extra configuration. 9 | 10 | You also need to install ``djangorestframework`` (or add it to your 11 | project dependencies):: 12 | 13 | pip install djangorestframework 14 | 15 | The default ``AdfsBackend`` backend expects an ``authorization_code``. The backend 16 | will take care of obtaining an ``access_code`` from the Adfs server. 17 | 18 | With the Django Rest Framework integration the client application needs to acquire 19 | the access token by itself. See for an example: :ref:`request-access-token`. To 20 | authenticate against the API you need to enable the ``AdfsAccessTokenBackend``. 21 | 22 | Steps to enable the Django Rest Framework integration are as following: 23 | 24 | Add an extra authentication class to Django Rest Framework in ``settings.py``: 25 | 26 | .. code-block:: python 27 | 28 | REST_FRAMEWORK = { 29 | 'DEFAULT_AUTHENTICATION_CLASSES': ( 30 | 'django_auth_adfs.rest_framework.AdfsAccessTokenAuthentication', 31 | 'rest_framework.authentication.SessionAuthentication', 32 | ) 33 | } 34 | 35 | Enable the ``AdfsAccessTokenBackend`` authentication backend in ``settings.py``: 36 | 37 | .. code-block:: python 38 | 39 | AUTHENTICATION_BACKENDS = ( 40 | ... 41 | 'django_auth_adfs.backend.AdfsAccessTokenBackend', 42 | ... 43 | ) 44 | 45 | Prevent your API from triggering a login redirect: 46 | 47 | .. code-block:: python 48 | 49 | AUTH_ADFS = { 50 | 'LOGIN_EXEMPT_URLS': [ 51 | '^api', # Assuming you API is available at /api 52 | ], 53 | } 54 | 55 | (Optional) Override the standard Django Rest Framework login pages in your main ``urls.py``: 56 | 57 | .. code-block:: python 58 | 59 | urlpatterns = [ 60 | ... 61 | # The default rest framework urls shouldn't be included 62 | # If we include them, we'll end up with the DRF login page, 63 | # instead of being redirected to the ADFS login page. 64 | # 65 | # path('api-auth/', include('rest_framework.urls')), 66 | # 67 | # This overrides the DRF login page 68 | path('oauth2/', include('django_auth_adfs.drf_urls')), 69 | ... 70 | ] 71 | 72 | .. _request-access-token: 73 | 74 | Requesting an access token 75 | -------------------------- 76 | 77 | When everything is configured, you can request an access token in your client (script) and 78 | access the api like this: 79 | 80 | .. note:: 81 | 82 | This example is written for ADFS on windows server 2016 but with some changes in the 83 | URLs should also work for Azure AD. 84 | 85 | .. code-block:: python 86 | 87 | import getpass 88 | import requests 89 | from pprint import pprint 90 | 91 | # Ask for password 92 | user = getpass.getuser() 93 | password = getpass.getpass("Password for "+user+": ") 94 | user = user + "@example.com" 95 | 96 | # Get an access token 97 | payload = { 98 | "grant_type": "password", 99 | "resource": "your-relying-party-id", 100 | "client_id": "your-configured-client-id", 101 | "username": user, 102 | "password": password, 103 | } 104 | response = requests.post( 105 | "https://adfs.example.com/adfs/oauth2/token", 106 | data=payload, 107 | verify=False 108 | ) 109 | response.raise_for_status() 110 | response_data = response.json() 111 | access_token = response_data['access_token'] 112 | 113 | # Make a request towards this API 114 | headers = { 115 | 'Accept': 'application/json', 116 | 'Authorization': 'Bearer ' + access_token, 117 | } 118 | response = requests.get( 119 | 'https://web.example.com/api/questions', 120 | headers=headers, 121 | verify=False 122 | ) 123 | pprint(response.json()) 124 | 125 | 126 | .. note:: 127 | 128 | The following example is written for ADFS on windows server 2012 R2 and needs 129 | the ``requests-ntlm`` module. 130 | 131 | This example is here only for legacy reasons. If possible it's advised to 132 | upgrade to 2016. Support for 2012 R2 is about to end. 133 | 134 | .. code-block:: python 135 | 136 | import getpass 137 | import re 138 | import requests 139 | from requests_ntlm import HttpNtlmAuth 140 | from pprint import pprint 141 | 142 | # Ask for password 143 | user = getpass.getuser() 144 | password = getpass.getpass("Password for "+user+": ") 145 | user = "EXAMPLE\\" + user 146 | 147 | # Get a authorization code 148 | headers = {"User-Agent": "Mozilla/5.0"} 149 | params = { 150 | "response_type": "code", 151 | "resource": "your-relying-party-id", 152 | "client_id": "your-configured-client-id", 153 | "redirect_uri": "https://djangoapp.example.com/oauth2/callback" 154 | } 155 | response = requests.get( 156 | "https://adfs.example.com/adfs/oauth2/authorize/wia", 157 | auth=HttpNtlmAuth(user, password), 158 | headers=headers, 159 | allow_redirects=False, 160 | params=params, 161 | ) 162 | response.raise_for_status() 163 | code = re.search('code=(.*)', response.headers['location']).group(1) 164 | 165 | # Get an access token 166 | data = { 167 | 'grant_type': 'authorization_code', 168 | 'client_id': 'your-configured-client-id', 169 | 'redirect_uri': 'https://djangoapp.example.com/oauth2/callback', 170 | 'code': code, 171 | } 172 | response = requests.post( 173 | "https://adfs.example.com/adfs/oauth2/token", 174 | data, 175 | ) 176 | response.raise_for_status() 177 | response_data = response.json() 178 | access_token = response_data['access_token'] 179 | 180 | # Make a request towards this API 181 | headers = { 182 | 'Accept': 'application/json', 183 | 'Authorization': 'Bearer %s' % access_token, 184 | } 185 | response = requests.get( 186 | 'https://djangoapp.example.com/v1/pets?name=rudolf', 187 | headers=headers 188 | ) 189 | pprint(response.json()) 190 | -------------------------------------------------------------------------------- /docs/signals.rst: -------------------------------------------------------------------------------- 1 | Django Signals 2 | ================ 3 | 4 | **django-auth-adfs** uses Django `Signals ` to allow the 5 | application to listen for and execute custom logic at certain points in the authentication process. Currently, the 6 | following signals are supported: 7 | 8 | * ``post_authenticate``: sent after a user has been authenticated through any subclass of ``AdfsBaseBackend``. The 9 | signal is sent after all other processing is done, e.g. mapping claims and groups and creating the user in Django (if 10 | :ref:`the CREATE_NEW_USERS setting ` is enabled). In addition to the sender, the signal 11 | includes the user object, the claims dictionary, and the ADFS response as arguments for the signal handler: 12 | 13 | * ``sender`` (``AdfsBaseBackend``): the backend instance from which the signal was triggered. 14 | * ``user`` (Django user class): the user object that was authenticated. 15 | * ``claims`` (``dict``): the decoded access token JWT, which contains all claims sent from the identity provider. 16 | * ``adfs_response`` (``dict|None``): used in the ``AdfsAuthCodeBackend`` to provide the full response received from 17 | the server when exchanging an authorization code for an access token. 18 | 19 | To use a signal in your application: 20 | 21 | .. code-block:: python 22 | 23 | from django.dispatch import receiver 24 | from django_auth_adfs.signals import post_authenticate 25 | 26 | 27 | @receiver(post_authenticate) 28 | def handle_post_authenticate(sender, user, claims, adfs_response=None, **kwargs): 29 | user.do_post_auth_steps(claims, adfs_response) 30 | 31 | 32 | -------------------------------------------------------------------------------- /docs/troubleshooting.rst: -------------------------------------------------------------------------------- 1 | Troubleshooting 2 | =============== 3 | 4 | Turn on Django debug logging 5 | ---------------------------- 6 | If you run into any problems, set the logging level in Django to DEBUG. 7 | You can do this by adding the configuration below to your ``settings.py`` 8 | 9 | You can see this logging in your console, or in you web server log if you're using something 10 | like Apache with mod_wsgi. 11 | 12 | More details about logging in Django can be found in 13 | `the official Django documentation `_ 14 | 15 | .. code-block:: python 16 | 17 | LOGGING = { 18 | 'version': 1, 19 | 'disable_existing_loggers': False, 20 | 'formatters': { 21 | 'verbose': { 22 | 'format': '%(levelname)s %(asctime)s %(name)s %(message)s' 23 | }, 24 | }, 25 | 'handlers': { 26 | 'console': { 27 | 'class': 'logging.StreamHandler', 28 | 'formatter': 'verbose' 29 | }, 30 | }, 31 | 'loggers': { 32 | 'django_auth_adfs': { 33 | 'handlers': ['console'], 34 | 'level': 'DEBUG', 35 | }, 36 | }, 37 | } 38 | 39 | Run Django with warnings enabled 40 | -------------------------------- 41 | Start the python interpreter that runs you Django with the ``-Wd`` parameter. This will show warnings that are otherwise 42 | suppressed. 43 | 44 | .. code-block:: bash 45 | 46 | python -Wd manage.py runserver 47 | 48 | Have a look at the demo project 49 | ------------------------------- 50 | There's an simple demo project available in the ``/demo`` folder and in the **demo** chapter of the documentation. 51 | 52 | If you compare the files in the ``adfs`` folder with those in the ``formsbased`` folder, you'll see what needs to be 53 | changed in a standard Django project to enable ADFS authentication. 54 | 55 | Besides that, there are a couple of PowerShell scripts available that are used while provisioning the ADFS server for 56 | the demo. you can find them in the ``/vagrant`` folder in this repository. They might be useful to figure out what is 57 | wrong with the configuration of your ADFS server. 58 | 59 | **Note that they are only meant for getting a demo running. By no means are they meant to configure your ADFS server.** 60 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = 'django-auth-adfs' 3 | version = "1.15.1" # Remember to also change __init__.py version 4 | description = 'A Django authentication backend for Microsoft ADFS and AzureAD' 5 | authors = ['Joris Beckers '] 6 | maintainers = ['Jonas Krüger Svensson ', 'Sondre Lillebø Gundersen '] 7 | license = 'BSD-1-Clause' 8 | homepage = 'https://github.com/snok/django-auth-adfs' 9 | repository = 'https://github.com/snok/django-auth-adfs' 10 | documentation = 'https://django-auth-adfs.readthedocs.io/en/latest' 11 | keywords = ['django', 'authentication', 'adfs', 'azure', 'ad', 'oauth2'] 12 | readme = 'README.rst' 13 | classifiers = [ 14 | 'Environment :: Web Environment', 15 | 'Framework :: Django :: 4.2', 16 | 'Framework :: Django :: 5.0', 17 | 'Framework :: Django :: 5.1', 18 | 'Framework :: Django :: 5.2', 19 | 'Intended Audience :: Developers', 20 | 'Intended Audience :: End Users/Desktop', 21 | 'Operating System :: OS Independent', 22 | 'License :: OSI Approved :: BSD License', 23 | 'Programming Language :: Python', 24 | 'Programming Language :: Python :: 3', 25 | 'Programming Language :: Python :: 3.9', 26 | 'Programming Language :: Python :: 3.10', 27 | 'Programming Language :: Python :: 3.11', 28 | 'Programming Language :: Python :: 3.12', 29 | 'Programming Language :: Python :: 3.13', 30 | 'Topic :: Internet :: WWW/HTTP', 31 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 32 | 'Topic :: Internet :: WWW/HTTP :: WSGI', 33 | 'Topic :: Software Development :: Libraries :: Application Frameworks', 34 | 'Topic :: Software Development :: Libraries :: Python Modules', 35 | 'Development Status :: 5 - Production/Stable', 36 | ] 37 | 38 | [tool.poetry.dependencies] 39 | python = '^3.9' 40 | django = [ 41 | { version = '^4.2', python = '>=3.9 <3.10' }, 42 | { version = '^4.2 || ^5', python = '>=3.10' }, 43 | ] 44 | requests = '*' 45 | urllib3 = '*' 46 | cryptography = '*' 47 | PyJWT = "*" 48 | 49 | [tool.poetry.dev-dependencies] 50 | responses = '*' 51 | mock = '*' 52 | coverage = '*' 53 | djangorestframework = '*' 54 | django-filter = "*" 55 | 56 | [build-system] 57 | requires = ["poetry-core>=1.0.0"] 58 | build-backend = "poetry.core.masonry.api" 59 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | exclude = 4 | docs/* 5 | .venv 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/custom_config.py: -------------------------------------------------------------------------------- 1 | class Settings(object): 2 | RETRIES = 1 3 | CA_BUNDLE = False 4 | 5 | def __init__(self): 6 | self.SERVER = 'custom-server' 7 | -------------------------------------------------------------------------------- /tests/mock_files/adfs-openid-configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "issuer": "https://adfs.example.com/adfs", 3 | "authorization_endpoint": "https://adfs.example.com/adfs/oauth2/authorize/", 4 | "token_endpoint": "https://adfs.example.com/adfs/oauth2/token/", 5 | "jwks_uri": "https://adfs.example.com/adfs/discovery/keys", 6 | "token_endpoint_auth_methods_supported": [ 7 | "client_secret_post", 8 | "client_secret_basic", 9 | "private_key_jwt", 10 | "windows_client_authentication" 11 | ], 12 | "response_types_supported": [ 13 | "code", 14 | "id_token", 15 | "code id_token", 16 | "id_token token", 17 | "code token", 18 | "code id_token token" 19 | ], 20 | "response_modes_supported": [ 21 | "query", 22 | "fragment", 23 | "form_post" 24 | ], 25 | "grant_types_supported": [ 26 | "authorization_code", 27 | "refresh_token", 28 | "client_credentials", 29 | "urn:ietf:params:oauth:grant-type:jwt-bearer", 30 | "implicit", 31 | "password", 32 | "srv_challenge" 33 | ], 34 | "subject_types_supported": [ 35 | "pairwise" 36 | ], 37 | "scopes_supported": [ 38 | "user_impersonation", 39 | "openid", 40 | "winhello_cert", 41 | "aza", 42 | "vpn_cert", 43 | "profile", 44 | "allatclaims", 45 | "email", 46 | "logon_cert" 47 | ], 48 | "id_token_signing_alg_values_supported": [ 49 | "RS256" 50 | ], 51 | "token_endpoint_auth_signing_alg_values_supported": [ 52 | "RS256" 53 | ], 54 | "access_token_issuer": "http://adfs.example.com/adfs/services/trust", 55 | "claims_supported": [ 56 | "aud", 57 | "iss", 58 | "iat", 59 | "exp", 60 | "auth_time", 61 | "nonce", 62 | "at_hash", 63 | "c_hash", 64 | "sub", 65 | "upn", 66 | "unique_name", 67 | "pwd_url", 68 | "pwd_exp", 69 | "sid" 70 | ], 71 | "microsoft_multi_refresh_token": true, 72 | "userinfo_endpoint": "https://adfs.example.com/adfs/userinfo", 73 | "capabilities": [], 74 | "end_session_endpoint": "https://adfs.example.com/adfs/oauth2/logout", 75 | "as_access_token_token_binding_supported": true, 76 | "as_refresh_token_token_binding_supported": true, 77 | "resource_access_token_token_binding_supported": true, 78 | "op_id_token_token_binding_supported": true, 79 | "rp_id_token_token_binding_supported": true, 80 | "frontchannel_logout_supported": true, 81 | "frontchannel_logout_session_supported": true 82 | } -------------------------------------------------------------------------------- /tests/mock_files/azure-openid-configuration-v2.json: -------------------------------------------------------------------------------- 1 | { 2 | "authorization_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/v2.0/authorize", 3 | "token_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/v2.0/token", 4 | "token_endpoint_auth_methods_supported": [ 5 | "client_secret_post", 6 | "private_key_jwt", 7 | "client_secret_basic" 8 | ], 9 | "jwks_uri": "https://login.microsoftonline.com/common/discovery/keys", 10 | "response_modes_supported": [ 11 | "query", 12 | "fragment", 13 | "form_post" 14 | ], 15 | "subject_types_supported": [ 16 | "pairwise" 17 | ], 18 | "id_token_signing_alg_values_supported": [ 19 | "RS256" 20 | ], 21 | "http_logout_supported": true, 22 | "frontchannel_logout_supported": true, 23 | "end_session_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/v2.0/logout", 24 | "response_types_supported": [ 25 | "code", 26 | "id_token", 27 | "code id_token", 28 | "token id_token", 29 | "token" 30 | ], 31 | "scopes_supported": [ 32 | "openid" 33 | ], 34 | "issuer": "https://sts.windows.net/01234567-89ab-cdef-0123-456789abcdef/", 35 | "claims_supported": [ 36 | "sub", 37 | "iss", 38 | "cloud_instance_name", 39 | "cloud_instance_host_name", 40 | "cloud_graph_host_name", 41 | "msgraph_host", 42 | "aud", 43 | "exp", 44 | "iat", 45 | "auth_time", 46 | "acr", 47 | "amr", 48 | "nonce", 49 | "email", 50 | "given_name", 51 | "family_name", 52 | "nickname" 53 | ], 54 | "microsoft_multi_refresh_token": true, 55 | "check_session_iframe": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/v2.0/checksession", 56 | "userinfo_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/openid/userinfo", 57 | "tenant_region_scope": "EU", 58 | "cloud_instance_name": "microsoftonline.com", 59 | "cloud_graph_host_name": "graph.windows.net", 60 | "msgraph_host": "graph.microsoft.com", 61 | "rbac_url": "https://pas.windows.net" 62 | } -------------------------------------------------------------------------------- /tests/mock_files/azure-openid-configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "authorization_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/authorize", 3 | "token_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/token", 4 | "token_endpoint_auth_methods_supported": [ 5 | "client_secret_post", 6 | "private_key_jwt", 7 | "client_secret_basic" 8 | ], 9 | "jwks_uri": "https://login.microsoftonline.com/common/discovery/keys", 10 | "response_modes_supported": [ 11 | "query", 12 | "fragment", 13 | "form_post" 14 | ], 15 | "subject_types_supported": [ 16 | "pairwise" 17 | ], 18 | "id_token_signing_alg_values_supported": [ 19 | "RS256" 20 | ], 21 | "http_logout_supported": true, 22 | "frontchannel_logout_supported": true, 23 | "end_session_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/logout", 24 | "response_types_supported": [ 25 | "code", 26 | "id_token", 27 | "code id_token", 28 | "token id_token", 29 | "token" 30 | ], 31 | "scopes_supported": [ 32 | "openid" 33 | ], 34 | "issuer": "https://sts.windows.net/01234567-89ab-cdef-0123-456789abcdef/", 35 | "claims_supported": [ 36 | "sub", 37 | "iss", 38 | "cloud_instance_name", 39 | "cloud_instance_host_name", 40 | "cloud_graph_host_name", 41 | "msgraph_host", 42 | "aud", 43 | "exp", 44 | "iat", 45 | "auth_time", 46 | "acr", 47 | "amr", 48 | "nonce", 49 | "email", 50 | "given_name", 51 | "family_name", 52 | "nickname" 53 | ], 54 | "microsoft_multi_refresh_token": true, 55 | "check_session_iframe": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/checksession", 56 | "userinfo_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/openid/userinfo", 57 | "tenant_region_scope": "EU", 58 | "cloud_instance_name": "microsoftonline.com", 59 | "cloud_graph_host_name": "graph.windows.net", 60 | "msgraph_host": "graph.microsoft.com", 61 | "rbac_url": "https://pas.windows.net" 62 | } -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import User 3 | 4 | 5 | class Profile(models.Model): 6 | user = models.OneToOneField(User, related_name="profile", on_delete=models.CASCADE) 7 | employee_id = models.IntegerField(blank=True, null=True) 8 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = 'secret' 2 | 3 | DATABASES = { 4 | 'default': { 5 | 'ENGINE': 'django.db.backends.sqlite3', 6 | 'NAME': ':memory:', 7 | 'USER': '', 8 | 'PASSWORD': '', 9 | 'HOST': '', 10 | 'PORT': '', 11 | } 12 | } 13 | 14 | TEMPLATES = [ 15 | { 16 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 17 | 'APP_DIRS': True, 18 | 'OPTIONS': { 19 | 'context_processors': [ 20 | 'django.template.context_processors.debug', 21 | 'django.template.context_processors.request', 22 | 'django.contrib.auth.context_processors.auth', 23 | 'django.contrib.messages.context_processors.messages', 24 | ], 25 | }, 26 | 'DIRS': 'templates' 27 | }, 28 | ] 29 | 30 | MIDDLEWARE = ( 31 | 'django.contrib.sessions.middleware.SessionMiddleware', 32 | 'django.middleware.common.CommonMiddleware', 33 | 'django.middleware.csrf.CsrfViewMiddleware', 34 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 35 | 'django.contrib.messages.middleware.MessageMiddleware', 36 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 37 | 38 | 'django_auth_adfs.middleware.LoginRequiredMiddleware', 39 | ) 40 | 41 | INSTALLED_APPS = ( 42 | 'django.contrib.admin', 43 | 'django.contrib.auth', 44 | 'django.contrib.contenttypes', 45 | 'django.contrib.sessions', 46 | 'django.contrib.messages', 47 | 'django.contrib.staticfiles', 48 | 49 | 'django_auth_adfs', 50 | 'tests', 51 | ) 52 | 53 | AUTHENTICATION_BACKENDS = ( 54 | "django.contrib.auth.backends.ModelBackend", 55 | 'django_auth_adfs.backend.AdfsAuthCodeBackend', 56 | 'django_auth_adfs.backend.AdfsAccessTokenBackend', 57 | ) 58 | 59 | ROOT_URLCONF = 'tests.urls' 60 | 61 | STATIC_ROOT = '/tmp/' # Dummy 62 | STATIC_URL = '/static/' 63 | 64 | AUTH_ADFS = { 65 | "SERVER": "adfs.example.com", 66 | "CLIENT_ID": "your-configured-client-id", 67 | "RELYING_PARTY_ID": "your-adfs-RPT-name", 68 | "AUDIENCE": "microsoft:identityserver:your-RelyingPartyTrust-identifier", 69 | "CA_BUNDLE": "/path/to/ca-bundle.pem", 70 | "CLAIM_MAPPING": {"first_name": "given_name", 71 | "last_name": "family_name", 72 | "email": "email"}, 73 | "BOOLEAN_CLAIM_MAPPING": {"is_staff": "user_is_staff", 74 | "is_superuser": "user_is_superuser"}, 75 | "CONFIG_RELOAD_INTERVAL": 0, # Always reload settings 76 | } 77 | 78 | LOGIN_URL = "django_auth_adfs:login" 79 | LOGIN_REDIRECT_URL = "/" 80 | -------------------------------------------------------------------------------- /tests/test_drf_integration.py: -------------------------------------------------------------------------------- 1 | import json 2 | from copy import deepcopy 3 | 4 | from django.test import RequestFactory, TestCase 5 | from mock import patch 6 | from rest_framework import exceptions 7 | from rest_framework.exceptions import AuthenticationFailed 8 | 9 | from django.contrib.auth.models import Group 10 | from django_auth_adfs.config import ProviderConfig, Settings 11 | from django_auth_adfs.rest_framework import AdfsAccessTokenAuthentication 12 | from .utils import build_access_token_adfs, build_access_token_azure, build_access_token_azure_guest, \ 13 | build_access_token_azure_guest_no_upn, build_access_token_azure_not_guest, \ 14 | build_access_token_azure_guest_with_idp, build_access_token_azure_groups_in_claim_source, \ 15 | mock_adfs 16 | 17 | 18 | class RestFrameworkIntegrationTests(TestCase): 19 | def setUp(self): 20 | self.drf_auth_class = AdfsAccessTokenAuthentication() 21 | 22 | adfs_response = build_access_token_adfs(RequestFactory().get('/'))[2] 23 | self.access_token_adfs = json.loads(adfs_response)['access_token'] 24 | 25 | azure_response = build_access_token_azure(RequestFactory().get('/'))[2] 26 | self.access_token_azure = json.loads(azure_response)['access_token'] 27 | 28 | azure_response_guest = build_access_token_azure_guest(RequestFactory().get('/'))[2] 29 | self.access_token_azure_guest = json.loads(azure_response_guest)['access_token'] 30 | 31 | azure_response_no_guest = build_access_token_azure_not_guest(RequestFactory().get('/'))[2] 32 | self.access_token_azure_no_guest = json.loads(azure_response_no_guest)['access_token'] 33 | 34 | azure_response_guest = build_access_token_azure_guest_no_upn(RequestFactory().get('/'))[2] 35 | self.access_token_azure_guest_no_upn = json.loads(azure_response_guest)['access_token'] 36 | 37 | azure_response_guest = build_access_token_azure_guest_with_idp(RequestFactory().get('/'))[2] 38 | self.access_token_azure_guest_with_idp = json.loads(azure_response_guest)['access_token'] 39 | 40 | azure_response = build_access_token_azure_groups_in_claim_source(RequestFactory().get('/'))[2] 41 | self.access_token_azure_groups_in_claim_source = json.loads(azure_response)['access_token'] 42 | 43 | Group.objects.create(name='group1') 44 | Group.objects.create(name='group2') 45 | Group.objects.create(name='group3') 46 | 47 | @mock_adfs("2012") 48 | def test_access_token_2012(self): 49 | access_token_header = "Bearer {}".format(self.access_token_adfs) 50 | request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) 51 | 52 | user, token = self.drf_auth_class.authenticate(request) 53 | self.assertEqual(user.username, "testuser") 54 | self.assertEqual(token, self.access_token_adfs.encode()) 55 | 56 | @mock_adfs("2016") 57 | def test_access_token_2016(self): 58 | access_token_header = "Bearer {}".format(self.access_token_adfs) 59 | request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) 60 | 61 | user, token = self.drf_auth_class.authenticate(request) 62 | self.assertEqual(user.username, "testuser") 63 | self.assertEqual(token, self.access_token_adfs.encode()) 64 | 65 | @mock_adfs("azure") 66 | def test_access_token_azure(self): 67 | access_token_header = "Bearer {}".format(self.access_token_azure) 68 | request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) 69 | 70 | from django_auth_adfs.config import django_settings 71 | settings = deepcopy(django_settings) 72 | del settings.AUTH_ADFS["SERVER"] 73 | settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" 74 | with patch("django_auth_adfs.config.django_settings", settings): 75 | with patch("django_auth_adfs.config.settings", Settings()): 76 | with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): 77 | user, token = self.drf_auth_class.authenticate(request) 78 | self.assertEqual(user.username, "testuser") 79 | 80 | @mock_adfs("azure") 81 | def test_access_token_azure_guest(self): 82 | access_token_header = "Bearer {}".format(self.access_token_azure_guest) 83 | request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) 84 | 85 | from django_auth_adfs.config import django_settings 86 | settings = deepcopy(django_settings) 87 | del settings.AUTH_ADFS["SERVER"] 88 | settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" 89 | settings.AUTH_ADFS["BLOCK_GUEST_USERS"] = True 90 | with patch("django_auth_adfs.config.django_settings", settings): 91 | with patch('django_auth_adfs.backend.settings', Settings()): 92 | with patch("django_auth_adfs.config.settings", Settings()): 93 | with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): 94 | with self.assertRaises(AuthenticationFailed): 95 | user, token = self.drf_auth_class.authenticate(request) 96 | 97 | @mock_adfs("azure") 98 | def test_access_token_azure_no_guest(self): 99 | access_token_header = "Bearer {}".format(self.access_token_azure_no_guest) 100 | request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) 101 | 102 | from django_auth_adfs.config import django_settings 103 | settings = deepcopy(django_settings) 104 | del settings.AUTH_ADFS["SERVER"] 105 | settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" 106 | settings.AUTH_ADFS["BLOCK_GUEST_USERS"] = True 107 | with patch("django_auth_adfs.config.django_settings", settings): 108 | with patch('django_auth_adfs.backend.settings', Settings()): 109 | with patch("django_auth_adfs.config.settings", Settings()): 110 | with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): 111 | user, token = self.drf_auth_class.authenticate(request) 112 | self.assertEqual(user.username, "testuser") 113 | 114 | @mock_adfs("azure") 115 | def test_access_token_azure_guest_but_no_upn(self): 116 | access_token_header = "Bearer {}".format(self.access_token_azure_guest_no_upn) 117 | request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) 118 | from django_auth_adfs.config import django_settings 119 | settings = deepcopy(django_settings) 120 | del settings.AUTH_ADFS["SERVER"] 121 | settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" 122 | settings.AUTH_ADFS["GUEST_USERNAME_CLAIM"] = "email" 123 | settings.AUTH_ADFS["BLOCK_GUEST_USERS"] = False 124 | with patch("django_auth_adfs.config.django_settings", settings): 125 | with patch('django_auth_adfs.backend.settings', Settings()): 126 | with patch("django_auth_adfs.config.settings", Settings()): 127 | with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): 128 | user, token = self.drf_auth_class.authenticate(request) 129 | self.assertEqual(user.username, "john.doe@example.com") 130 | 131 | @mock_adfs("azure") 132 | def test_access_token_azure_guest_with_idp(self): 133 | access_token_header = "Bearer {}".format(self.access_token_azure_guest_with_idp) 134 | request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) 135 | from django_auth_adfs.config import django_settings 136 | settings = deepcopy(django_settings) 137 | del settings.AUTH_ADFS["SERVER"] 138 | settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" 139 | settings.AUTH_ADFS["GUEST_USERNAME_CLAIM"] = "email" 140 | settings.AUTH_ADFS["BLOCK_GUEST_USERS"] = False 141 | with patch("django_auth_adfs.config.django_settings", settings): 142 | with patch('django_auth_adfs.backend.settings', Settings()): 143 | with patch("django_auth_adfs.config.settings", Settings()): 144 | with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): 145 | user, token = self.drf_auth_class.authenticate(request) 146 | self.assertEqual(user.username, "john.doe@example.com") 147 | 148 | @mock_adfs("azure") 149 | def test_access_token_azure_guest_but_no_upn_but_no_guest_username_claim(self): 150 | access_token_header = "Bearer {}".format(self.access_token_azure_guest_no_upn) 151 | request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) 152 | from django_auth_adfs.config import django_settings 153 | settings = deepcopy(django_settings) 154 | del settings.AUTH_ADFS["SERVER"] 155 | settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" 156 | settings.AUTH_ADFS["GUEST_USERNAME_CLAIM"] = None # <--- Set to None, should not be validated as OK 157 | settings.AUTH_ADFS["BLOCK_GUEST_USERS"] = False 158 | with patch("django_auth_adfs.config.django_settings", settings): 159 | with patch('django_auth_adfs.backend.settings', Settings()): 160 | with patch("django_auth_adfs.config.settings", Settings()): 161 | with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): 162 | with self.assertRaises(exceptions.AuthenticationFailed): 163 | self.drf_auth_class.authenticate(request) 164 | 165 | @mock_adfs("azure", requires_obo=True) 166 | def test_process_group_claim_from_ms_graph(self): 167 | access_token_header = "Bearer {}".format(self.access_token_azure_groups_in_claim_source) 168 | request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) 169 | 170 | from django_auth_adfs.config import django_settings 171 | settings = deepcopy(django_settings) 172 | del settings.AUTH_ADFS["SERVER"] 173 | settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" 174 | with patch("django_auth_adfs.config.django_settings", settings): 175 | with patch('django_auth_adfs.backend.settings', Settings()): 176 | with patch("django_auth_adfs.config.settings", Settings()): 177 | with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): 178 | user, _ = self.drf_auth_class.authenticate(request) 179 | self.assertEqual(user.username, "testuser") 180 | self.assertEqual(user.groups.all()[0].name, "group1") 181 | self.assertEqual(user.groups.all()[1].name, "group2") 182 | 183 | @mock_adfs("azure", requires_obo=True, mfa_error=True) 184 | def test_get_obo_access_token_mfa_error(self): 185 | access_token_header = "Bearer {}".format(self.access_token_azure_groups_in_claim_source) 186 | request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) 187 | 188 | from django_auth_adfs.config import django_settings 189 | settings = deepcopy(django_settings) 190 | del settings.AUTH_ADFS["SERVER"] 191 | settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" 192 | with patch("django_auth_adfs.config.django_settings", settings): 193 | with patch('django_auth_adfs.backend.settings', Settings()): 194 | with patch("django_auth_adfs.config.settings", Settings()): 195 | with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): 196 | with self.assertRaises(AuthenticationFailed): 197 | self.drf_auth_class.authenticate(request) 198 | 199 | @mock_adfs("azure", requires_obo=True, version='v2.0') 200 | def test_get_obo_access_token_version_2(self): 201 | access_token_header = "Bearer {}".format(self.access_token_azure_groups_in_claim_source) 202 | request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) 203 | 204 | from django_auth_adfs.config import django_settings 205 | settings = deepcopy(django_settings) 206 | del settings.AUTH_ADFS["SERVER"] 207 | settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" 208 | settings.AUTH_ADFS["VERSION"] = 'v2.0' 209 | with patch("django_auth_adfs.config.django_settings", settings): 210 | with patch('django_auth_adfs.backend.settings', Settings()): 211 | with patch("django_auth_adfs.config.settings", Settings()): 212 | with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): 213 | user, _ = self.drf_auth_class.authenticate(request) 214 | self.assertEqual(user.username, "testuser") 215 | self.assertEqual(user.groups.all()[0].name, "group1") 216 | self.assertEqual(user.groups.all()[1].name, "group2") 217 | 218 | @mock_adfs("azure", requires_obo=True, missing_graph_group_perm=True) 219 | def test_missing_ms_graph_group_permission(self): 220 | access_token_header = "Bearer {}".format(self.access_token_azure_groups_in_claim_source) 221 | request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) 222 | 223 | from django_auth_adfs.config import django_settings 224 | settings = deepcopy(django_settings) 225 | del settings.AUTH_ADFS["SERVER"] 226 | settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" 227 | with patch("django_auth_adfs.config.django_settings", settings): 228 | with patch('django_auth_adfs.backend.settings', Settings()): 229 | with patch("django_auth_adfs.config.settings", Settings()): 230 | with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): 231 | with self.assertRaises(AuthenticationFailed): 232 | self.drf_auth_class.authenticate(request) 233 | 234 | @mock_adfs("2012") 235 | def test_access_token_exceptions(self): 236 | access_token_header = "Bearer non-existing-token" 237 | request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) 238 | 239 | with self.assertRaises(exceptions.AuthenticationFailed): 240 | self.drf_auth_class.authenticate(request) 241 | 242 | # use the azure token on adfs should not work 243 | access_token_header = "Bearer {}".format(self.access_token_azure) 244 | request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) 245 | 246 | with self.assertRaises(exceptions.AuthenticationFailed): 247 | self.drf_auth_class.authenticate(request) 248 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from copy import deepcopy 3 | 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.test import TestCase, SimpleTestCase, override_settings 6 | from mock import patch 7 | from django_auth_adfs.config import django_settings 8 | from django_auth_adfs.config import Settings 9 | from django_auth_adfs.config import ProviderConfig 10 | from .custom_config import Settings as CustomSettings 11 | 12 | 13 | class SettingsTests(TestCase): 14 | def test_no_settings(self): 15 | settings = deepcopy(django_settings) 16 | del settings.AUTH_ADFS 17 | with patch("django_auth_adfs.config.django_settings", settings): 18 | with self.assertRaises(ImproperlyConfigured): 19 | Settings() 20 | 21 | def test_claim_mapping_overlapping_username_field(self): 22 | settings = deepcopy(django_settings) 23 | settings.AUTH_ADFS["CLAIM_MAPPING"] = {"username": "samaccountname"} 24 | with patch("django_auth_adfs.config.django_settings", settings): 25 | with self.assertRaises(ImproperlyConfigured): 26 | Settings() 27 | 28 | def test_tenant_and_server(self): 29 | settings = deepcopy(django_settings) 30 | settings.AUTH_ADFS["TENANT_ID"] = "abc" 31 | settings.AUTH_ADFS["SERVER"] = "abc" 32 | with patch("django_auth_adfs.config.django_settings", settings): 33 | with self.assertRaises(ImproperlyConfigured): 34 | Settings() 35 | 36 | def test_no_tenant_but_block_guest(self): 37 | settings = deepcopy(django_settings) 38 | settings.AUTH_ADFS["SERVER"] = "abc" 39 | settings.AUTH_ADFS["BLOCK_GUEST_USERS"] = True 40 | with patch("django_auth_adfs.config.django_settings", settings): 41 | with self.assertRaises(ImproperlyConfigured): 42 | Settings() 43 | 44 | def test_tenant_with_block_users(self): 45 | settings = deepcopy(django_settings) 46 | del settings.AUTH_ADFS["SERVER"] 47 | settings.AUTH_ADFS["TENANT_ID"] = "abc" 48 | settings.AUTH_ADFS["BLOCK_GUEST_USERS"] = True 49 | with patch("django_auth_adfs.config.django_settings", settings): 50 | current_settings = Settings() 51 | self.assertTrue(current_settings.BLOCK_GUEST_USERS) 52 | 53 | def test_unknown_setting(self): 54 | settings = deepcopy(django_settings) 55 | settings.AUTH_ADFS["dummy"] = "abc" 56 | with patch("django_auth_adfs.config.django_settings", settings): 57 | with self.assertRaises(ImproperlyConfigured): 58 | Settings() 59 | 60 | def test_required_setting(self): 61 | settings = deepcopy(django_settings) 62 | del settings.AUTH_ADFS["AUDIENCE"] 63 | with patch("django_auth_adfs.config.django_settings", settings): 64 | with self.assertRaises(ImproperlyConfigured): 65 | Settings() 66 | 67 | def test_default_failed_response_setting(self): 68 | settings = deepcopy(django_settings) 69 | with patch("django_auth_adfs.config.django_settings", settings): 70 | s = Settings() 71 | self.assertTrue(callable(s.CUSTOM_FAILED_RESPONSE_VIEW)) 72 | 73 | def test_dotted_path_failed_response_setting(self): 74 | settings = deepcopy(django_settings) 75 | settings.AUTH_ADFS["CUSTOM_FAILED_RESPONSE_VIEW"] = 'tests.views.test_failed_response' 76 | with patch("django_auth_adfs.config.django_settings", settings): 77 | s = Settings() 78 | self.assertTrue(callable(s.CUSTOM_FAILED_RESPONSE_VIEW)) 79 | 80 | def test_settings_version(self): 81 | settings = deepcopy(django_settings) 82 | current_settings = Settings() 83 | self.assertEqual(current_settings.VERSION, "v1.0") 84 | settings.AUTH_ADFS["TENANT_ID"] = "abc" 85 | del settings.AUTH_ADFS["SERVER"] 86 | settings.AUTH_ADFS["VERSION"] = "v2.0" 87 | with patch("django_auth_adfs.config.django_settings", settings): 88 | current_settings = Settings() 89 | self.assertEqual(current_settings.VERSION, "v2.0") 90 | 91 | def test_not_azure_but_version_is_set(self): 92 | settings = deepcopy(django_settings) 93 | settings.AUTH_ADFS["SERVER"] = "abc" 94 | settings.AUTH_ADFS["VERSION"] = "v2.0" 95 | with patch("django_auth_adfs.config.django_settings", settings): 96 | with self.assertRaises(ImproperlyConfigured): 97 | Settings() 98 | 99 | def test_configured_proxy(self): 100 | settings = Settings() 101 | settings.PROXIES = {'http': '10.0.0.1'} 102 | with patch("django_auth_adfs.config.settings", settings): 103 | provider_config = ProviderConfig() 104 | self.assertEqual(provider_config.session.proxies, {'http': '10.0.0.1'}) 105 | 106 | def test_no_configured_proxy(self): 107 | provider_config = ProviderConfig() 108 | self.assertIsNone(provider_config.session.proxies) 109 | 110 | 111 | class CustomSettingsTests(SimpleTestCase): 112 | def setUp(self): 113 | sys.modules.pop('django_auth_adfs.config', None) 114 | 115 | def tearDown(self): 116 | sys.modules.pop('django_auth_adfs.config', None) 117 | 118 | def test_dotted_path(self): 119 | auth_adfs = deepcopy(django_settings).AUTH_ADFS 120 | auth_adfs['SETTINGS_CLASS'] = 'tests.custom_config.Settings' 121 | 122 | with override_settings(AUTH_ADFS=auth_adfs): 123 | from django_auth_adfs.config import settings 124 | self.assertIsInstance(settings, CustomSettings) 125 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, re_path 2 | 3 | urlpatterns = [ 4 | re_path(r'^oauth2/', include('django_auth_adfs.urls')), 5 | re_path(r'^oauth2/', include('django_auth_adfs.drf_urls')), 6 | ] 7 | -------------------------------------------------------------------------------- /tests/views.py: -------------------------------------------------------------------------------- 1 | def test_failed_response(request, error_message, status): 2 | pass 3 | -------------------------------------------------------------------------------- /vagrant/01-setup-domain.ps1: -------------------------------------------------------------------------------- 1 | # ########## SETTINGS ######### 2 | $domainName = "example.com" 3 | $netbiosName = "EXAMPLE" 4 | $safeModePwd = "Password123" 5 | # ############################# 6 | Set-LocalUser ` 7 | -name "administrator" ` 8 | -AccountNeverExpires ` 9 | -Password (Convertto-SecureString -AsPlainText "Vagrant123" -Force) ` 10 | -PasswordNeverExpires $true 11 | # Install and configure domain controller role 12 | # -------------------------------------------- 13 | Write-Host "Installing domain features..." 14 | Install-WindowsFeature -Name AD-Domain-Services -IncludeManagementTools 15 | Write-Host "Promoting DC..." 16 | Import-Module ADDSDeployment 17 | Install-ADDSForest ` 18 | -CreateDnsDelegation:$false ` 19 | -DatabasePath "C:\Windows\NTDS" ` 20 | -DomainMode "WinThreshold" ` 21 | -DomainName $domainName ` 22 | -DomainNetbiosName $netbiosName ` 23 | -ForestMode "WinThreshold" ` 24 | -InstallDns:$true ` 25 | -LogPath "C:\Windows\NTDS" ` 26 | -SysvolPath "C:\Windows\SYSVOL" ` 27 | -Force:$true ` 28 | -SafeModeAdministratorPassword (Convertto-SecureString -AsPlainText $safeModePwd -Force) ` 29 | -NoRebootOnCompletion 30 | -------------------------------------------------------------------------------- /vagrant/02-setup-vagrant-user.ps1: -------------------------------------------------------------------------------- 1 | Write-Host "Waiting for domain controller to become reachable." 2 | $isUp = $false 3 | while($isUp -eq $false) { 4 | Try { 5 | $domain = Get-ADDomain 6 | $isUp = $true 7 | } Catch [Microsoft.ActiveDirectory.Management.ADServerDownException] { 8 | Write-Host "Retrying in 30 seconds" 9 | $isUp = $false 10 | Start-Sleep 30 11 | } 12 | } 13 | 14 | Add-ADGroupMember -Identity "Domain Admins" -Members vagrant 15 | Add-ADGroupMember -Identity "Enterprise Admins" -Members vagrant 16 | Add-ADGroupMember -Identity "Schema Admins" -Members vagrant 17 | -------------------------------------------------------------------------------- /vagrant/03-setup-adfs.ps1: -------------------------------------------------------------------------------- 1 | # ########## SETTINGS ######### 2 | $adfsHost = "adfs" 3 | # ############################# 4 | 5 | Write-Host "Waiting for domain controller to become reachable." 6 | $isUp = $false 7 | while($isUp -eq $false) { 8 | Try { 9 | $domain = Get-ADDomain 10 | $isUp = $true 11 | } Catch [Microsoft.ActiveDirectory.Management.ADServerDownException] { 12 | Write-Host "Retrying in 30 seconds" 13 | $isUp = $false 14 | Start-Sleep 30 15 | } 16 | } 17 | 18 | # Install the ADFS role 19 | # --------------------- 20 | Write-Host "Installing ADFS role..." 21 | Install-WindowsFeature -Name ADFS-Federation -IncludeManagementTools 22 | 23 | # Add ADFS DNS record 24 | # ------------------- 25 | Write-Host "Adding DNS record..." 26 | $ip = Get-NetIPAddress -InterfaceAlias "Ethernet 2" -AddressFamily ipv4 27 | Add-DnsServerResourceRecordA -Name $adfsHost -IPv4Address $ip.IPAddress -ZoneName (Get-ADDomain).Forest 28 | 29 | 30 | # Generate ADFS certificate 31 | # ------------------------- 32 | Write-Host "Generating self signed certificate for ADFS..." 33 | 34 | Import-Module \\vboxsrv\vagrant\vagrant\New-SelfSignedCertificateEx.ps1 35 | $cert = New-SelfSignedCertificateEx ` 36 | -Subject ("CN="+$adfsHost+"."+(Get-ADDomain).Forest) ` 37 | -SubjectAlternativeName ($adfsHost+"."+(Get-ADDomain).Forest) ` 38 | -AlgorithmName RSA ` 39 | -KeyLength 2048 ` 40 | -SignatureAlgorithm SHA256 ` 41 | -StoreLocation LocalMachine 42 | 43 | # Configure ADFS 44 | # -------------- 45 | Write-Host "Configure ADFS..." 46 | # Needed to be able to create a group Managed Service Account 47 | # set-service kdssvc -StartupType Automatic 48 | Add-KdsRootKey -EffectiveTime (Get-Date).AddHours(-10) 49 | 50 | Write-Host "Creating Group Managed Service Account..." 51 | $Name = 'FsGmsa' 52 | $DNS_Name = $adfsHost+"."+(Get-ADDomain).Forest 53 | New-ADServiceAccount -Name $Name -DNSHostName $DNS_Name -PrincipalsAllowedToRetrieveManagedPassword "$env:computername`$" 54 | 55 | Import-Module ADFS 56 | Install-AdfsFarm ` 57 | -CertificateThumbprint $cert.Thumbprint ` 58 | -FederationServiceDisplayName "Example Corp" ` 59 | -FederationServiceName ($adfsHost+"."+(Get-ADDomain).Forest) ` 60 | -GroupServiceAccountIdentifier ((Get-ADDomain).NetBIOSName + "\FsGmsa`$") ` 61 | -OverwriteConfiguration 62 | 63 | # https://social.technet.microsoft.com/Forums/office/en-US/a290c5c0-3112-409f-8cb0-ff23e083e5d1/ad-fs-windows-2012-r2-adfssrv-hangs-in-starting-mode?forum=winserverDS 64 | sc.exe triggerinfo kdssvc start/networkon 65 | -------------------------------------------------------------------------------- /vagrant/04-example-adfs-config.ps1: -------------------------------------------------------------------------------- 1 | # ########## SETTINGS ######### 2 | $webIP = "10.10.10.10" 3 | $webName = "web" 4 | $appName = "Django Application" 5 | $clientId = "487d8ff7-80a8-4f62-b926-c2852ab06e94" 6 | $relyingPartyId = "web.example.com" 7 | # ############################# 8 | 9 | Write-Host "Waiting for domain controller to become reachable." 10 | $isUp = $false 11 | while($isUp -eq $false) { 12 | Try { 13 | $domain = Get-ADDomain 14 | $isUp = $true 15 | } Catch { 16 | Write-Host "Retrying in 15 seconds" 17 | $isUp = $false 18 | Start-Sleep 15 19 | } 20 | } 21 | 22 | # Add webserver DNS record 23 | # ------------------------ 24 | Write-Host "Adding DNS record..." 25 | Add-DnsServerResourceRecordA -Name $webName -IPv4Address $webIP -ZoneName (Get-ADDomain).Forest 26 | 27 | # Add example users and groups 28 | # ---------------------------- 29 | Write-Host "Creating Django Admins group" 30 | $staffGroup = New-ADGroup ` 31 | -Name "Django Admins" ` 32 | -SamAccountName django_admins ` 33 | -GroupCategory Security ` 34 | -GroupScope Global ` 35 | -Passthru 36 | 37 | Write-Host "Creating user Alice..." 38 | New-ADUser ` 39 | -Name "Alice" ` 40 | -GivenName Alice ` 41 | -SurName Wonder ` 42 | -SamAccountName alice ` 43 | -EmailAddress ("alice@"+(Get-ADDomain).Forest) ` 44 | -UserPrincipalName ("alice@"+(Get-ADDomain).Forest) ` 45 | -AccountPassword (convertto-securestring "Password123" -asplaintext -force) ` 46 | -Enabled $true 47 | 48 | Write-Host "Creating user Bob..." 49 | $bob = New-ADUser ` 50 | -Name "Bob" ` 51 | -GivenName Bob ` 52 | -SurName Builder ` 53 | -SamAccountName bob ` 54 | -EmailAddress ("bob@"+(Get-ADDomain).Forest) ` 55 | -UserPrincipalName ("bob@"+(Get-ADDomain).Forest) ` 56 | -AccountPassword (convertto-securestring "Password123" -asplaintext -force) ` 57 | -Enabled $true ` 58 | -Passthru 59 | 60 | Add-ADGroupMember -Identity django_admins -Members $bob 61 | 62 | Write-Host "Disabling Internet Explorer Enhanced Security Configuration" 63 | $AdminKey = "HKLM:\SOFTWARE\Microsoft\Active Setup\Installed Components\{A509B1A7-37EF-4b3f-8CFC-4F3A74704073}" 64 | $UserKey = "HKLM:\SOFTWARE\Microsoft\Active Setup\Installed Components\{A509B1A8-37EF-4b3f-8CFC-4F3A74704073}" 65 | Set-ItemProperty -Path $AdminKey -Name "IsInstalled" -Value 0 66 | Set-ItemProperty -Path $UserKey -Name "IsInstalled" -Value 0 67 | 68 | # Add ADFS config 69 | # --------------- 70 | 71 | Write-Host "Waiting for Federation Server to become reachable." 72 | $isUp = $false 73 | while($isUp -eq $false) { 74 | Try { 75 | $domain = Get-AdfsProperties 76 | $isUp = $true 77 | } Catch { 78 | Write-Host "Retrying in 15 seconds" 79 | $isUp = $false 80 | Start-Sleep 15 81 | } 82 | } 83 | 84 | Write-Host "Adding application group $appName" 85 | New-AdfsApplicationGroup -Name $appName -ApplicationGroupIdentifier $appName 86 | 87 | Write-Host "Adding native application" 88 | Add-AdfsNativeClientApplication ` 89 | -name "$appName - Native application" ` 90 | -Identifier $clientId ` 91 | -ApplicationGroupIdentifier $appName ` 92 | -RedirectUri ("http://$webName."+(Get-ADDomain).Forest+":8000/oauth2/callback") 93 | 94 | Write-Host "Adding web application" 95 | Add-AdfsWebApiApplication ` 96 | -Name "$appName - Web application" ` 97 | -Identifier $relyingPartyId ` 98 | -AccessControlPolicyName "Permit everyone" ` 99 | -ApplicationGroupIdentifier $appName ` 100 | -IssuanceTransformRules ( 101 | '@RuleTemplate = "LdapClaims" 102 | @RuleName = "User attribute claims" 103 | c:[ 104 | Type == "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname", 105 | Issuer == "AD AUTHORITY" 106 | ] 107 | => issue( 108 | store = "Active Directory", 109 | types = ( 110 | "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", 111 | "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname", 112 | "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname", 113 | "http://schemas.xmlsoap.org/claims/Group", 114 | "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname" 115 | ), 116 | query = ";mail,givenName,sn,tokenGroups,sAMAccountName;{0}", 117 | param = c.Value 118 | );' 119 | ) 120 | 121 | Write-Host "Adding native application" 122 | Grant-AdfsApplicationPermission ` 123 | -ClientRoleIdentifier $clientId ` 124 | -ServerRoleIdentifier $relyingPartyId ` 125 | -ScopeNames "openid" 126 | -------------------------------------------------------------------------------- /vagrant/README.rst: -------------------------------------------------------------------------------- 1 | ADFS Setup Scripts for vagrant 2 | ============================== 3 | 4 | This directory contains scripts used by Vagrant while bringing up test virtual machines. 5 | you can use these scripts as an example for setting up your own ADFS environment. 6 | 7 | .. warning:: 8 | 9 | These scripts are meant for setting up a lab and are not meant for setting up a secure production environment. 10 | --------------------------------------------------------------------------------