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

Polls Administration

{% endblock %} {% block nav-global %}{% endblock %} ================================================ FILE: demo/adfs/polls/templates/polls/detail.html ================================================ {% extends "base.html" %} {% block content %}

{{ question.question_text }}

    {% for choice in question.choice_set.all %}
  • {{ choice.choice_text }} {{ choice.votes }}
  • {% endfor %}
{% endblock %} ================================================ FILE: demo/adfs/polls/templates/polls/index.html ================================================ {% extends "base.html" %} {% block content %}

Available polls

{% if latest_question_list %}
{% for question in latest_question_list %} {{ question.question_text }} {% endfor %}
{% else %}

No polls are available.

{% endif %} {% endblock %} ================================================ FILE: demo/adfs/polls/templates/polls/vote.html ================================================ {% extends "base.html" %} {% block content %}

{{ question.question_text }}

{% if error_message %}

{{ error_message }}

{% endif %}
{% csrf_token %} {% for choice in question.choice_set.all %}
{% endfor %}
{% endblock %} ================================================ FILE: demo/adfs/polls/urls.py ================================================ from django.urls import path from . import views app_name = 'polls' urlpatterns = [ path('', views.IndexView.as_view(), name='index'), path('/', views.DetailView.as_view(), name='detail'), path('/vote/', views.VoteView.as_view(), name='vote'), # path('/savevote/', views.savevote, name='savevote'), ] ================================================ FILE: demo/adfs/polls/views.py ================================================ from django.contrib.auth.mixins import LoginRequiredMixin from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, render from django.urls import reverse from django.utils import timezone from django.views import generic from .models import Choice, Question class IndexView(generic.ListView): template_name = 'polls/index.html' context_object_name = 'latest_question_list' def get_queryset(self): """ Return the last five published questions (not including those set to be published in the future). """ return Question.objects.filter( pub_date__lte=timezone.now() ).order_by('-pub_date')[:5] class DetailView(generic.DetailView): model = Question template_name = 'polls/detail.html' def get_queryset(self): """ Excludes any questions that aren't published yet. """ return Question.objects.filter(pub_date__lte=timezone.now()) class VoteView(LoginRequiredMixin, generic.DetailView): model = Question template_name = 'polls/vote.html' def get_queryset(self): """ Excludes any questions that aren't published yet. """ return Question.objects.filter(pub_date__lte=timezone.now()) def post(self, request, pk, *args, **kwargs): question = get_object_or_404(Question, pk=pk) try: selected_choice = question.choice_set.get(pk=request.POST['choice']) except (KeyError, Choice.DoesNotExist): # Redisplay the question voting form. return render(request, 'polls/vote.html', { 'question': question, 'error_message': "You didn't select a choice.", }) else: selected_choice.vote() # Always return an HttpResponseRedirect after successfully dealing # with POST data. This prevents data from being posted twice if a # user hits the Back button. return HttpResponseRedirect(reverse('polls:detail', args=(question.id,))) ================================================ FILE: demo/adfs/templates/base.html ================================================ {% load static %} {% block title %}Polls App{% endblock %}
{% block content %}{% endblock %}
Current User Info
id         = {{ user.id }}
username   = {{ user.username }}
first_name = {{ user.first_name }}
last_name  = {{ user.last_name }}
email      = {{ user.email }}
is_authenticated = {{ user.is_authenticated }}
is_staff         = {{ user.is_staff }}
is_active        = {{ user.is_active }}
is_superuser     = {{ user.is_superuser }}
last_login  = {{ user.last_login }}
date_joined = {{ user.date_joined }}
================================================ FILE: demo/adfs/templates/home.html ================================================ {% extends 'base.html' %} {% block title %}Home{% endblock %} {% block content %}

Welcome to the polls app

Use the menu above to navigate

{% endblock %} ================================================ FILE: demo/formsbased/manage.py ================================================ #!/usr/bin/env python import os import sys if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: raise ImportError( "Couldn't import Django. Are you sure it's installed and " "available on your PYTHONPATH environment variable? Did you " "forget to activate a virtual environment?" ) from exc execute_from_command_line(sys.argv) ================================================ FILE: demo/formsbased/mysite/__init__.py ================================================ ================================================ FILE: demo/formsbased/mysite/settings.py ================================================ """ Django settings for mysite project. Generated by 'django-admin startproject' using Django 2.0.5. For more information on this file, see https://docs.djangoproject.com/en/2.0/topics/settings/ For the full list of settings and their values, see https://docs.djangoproject.com/en/2.0/ref/settings/ """ import os # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = '72zx9=7byz54=z-@oyuv^h)nse=qljty65zj$nj*$z42j353sa' # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True ALLOWED_HOSTS = ['*'] # Application definition INSTALLED_APPS = [ 'polls', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', ] MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] ROOT_URLCONF = 'mysite.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'DIRS': [os.path.join(BASE_DIR, 'templates')], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ 'django.template.context_processors.debug', 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', ], }, }, ] WSGI_APPLICATION = 'mysite.wsgi.application' # Database # https://docs.djangoproject.com/en/2.0/ref/settings/#databases DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), } } # Password validation # https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', }, { 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', }, { 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', }, { 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', }, ] # Internationalization # https://docs.djangoproject.com/en/2.0/topics/i18n/ LANGUAGE_CODE = 'en-us' TIME_ZONE = 'UTC' USE_I18N = True USE_L10N = True USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/2.0/howto/static-files/ STATIC_URL = '/static/' STATIC_ROOT = os.path.join(BASE_DIR, "static") LOGIN_REDIRECT_URL = "home" REST_FRAMEWORK = { 'DEFAULT_PERMISSION_CLASSES': ( 'rest_framework.permissions.IsAuthenticatedOrReadOnly', ), 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework.authentication.SessionAuthentication', ) } ================================================ FILE: demo/formsbased/mysite/urls.py ================================================ """mysite URL Configuration The `urlpatterns` list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/2.0/topics/http/urls/ Examples: Function views 1. Add an import: from my_app import views 2. Add a URL to urlpatterns: path('', views.home, name='home') Class-based views 1. Add an import: from other_app.views import Home 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') Including another URLconf 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.conf import settings from django.conf.urls.static import static from django.contrib import admin from django.contrib.auth import views as auth_views from django.urls import include, path from django.views.generic.base import TemplateView urlpatterns = [ path('', TemplateView.as_view(template_name='home.html'), name='home'), path('polls/', include('polls.urls')), path('api/', include('polls.api.urls')), path('api-auth/', include('rest_framework.urls')), path('admin/', admin.site.urls, name='admin'), path('accounts/login/', auth_views.LoginView.as_view(), name='login'), path('accounts/logout/', auth_views.LogoutView.as_view(), name='logout'), ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) ================================================ FILE: demo/formsbased/mysite/wsgi.py ================================================ """ WSGI config for mysite project. It exposes the WSGI callable as a module-level variable named ``application``. For more information on this file, see https://docs.djangoproject.com/en/2.0/howto/deployment/wsgi/ """ import os from django.core.wsgi import get_wsgi_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") application = get_wsgi_application() ================================================ FILE: demo/formsbased/polls/__init__.py ================================================ ================================================ FILE: demo/formsbased/polls/admin.py ================================================ from django.contrib import admin from .models import Choice, Question class ChoiceInline(admin.TabularInline): model = Choice extra = 3 class QuestionAdmin(admin.ModelAdmin): fieldsets = [ (None, {'fields': ['question_text']}), ('Date information', {'fields': ['pub_date'], 'classes': ['collapse']}), ] inlines = [ChoiceInline] list_display = ('question_text', 'pub_date', 'was_published_recently') list_filter = ['pub_date'] search_fields = ['question_text'] admin.site.register(Question, QuestionAdmin) ================================================ FILE: demo/formsbased/polls/api/__init__.py ================================================ ================================================ FILE: demo/formsbased/polls/api/filters.py ================================================ import django_filters from ..models import Choice, Question class QuestionFilter(django_filters.FilterSet): class Meta: model = Question fields = ['question_text', 'pub_date'] class ChoiceFilter(django_filters.FilterSet): class Meta: model = Choice fields = ['question', 'choice_text', 'votes'] ================================================ FILE: demo/formsbased/polls/api/serializers.py ================================================ from ..models import Choice, Question import rest_framework.serializers as serializers class QuestionSerializer(serializers.ModelSerializer): class Meta: model = Question fields = ['id', 'question_text', 'pub_date'] class ChoiceSerializer(serializers.ModelSerializer): votes = serializers.IntegerField(read_only=True) class Meta: model = Choice fields = ['id', 'question', 'choice_text', 'votes'] ================================================ FILE: demo/formsbased/polls/api/urls.py ================================================ from rest_framework import routers from . import views router = routers.DefaultRouter() router.register(r'questions', views.QuestionViewSet) router.register(r'choices', views.ChoiceViewSet) app_name = 'polls-api' urlpatterns = router.urls ================================================ FILE: demo/formsbased/polls/api/views.py ================================================ from rest_framework.viewsets import ModelViewSet from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from ..models import Question, Choice from .serializers import QuestionSerializer, ChoiceSerializer from .filters import QuestionFilter, ChoiceFilter class QuestionViewSet(ModelViewSet): queryset = Question.objects.all() serializer_class = QuestionSerializer filter_class = QuestionFilter class ChoiceViewSet(ModelViewSet): queryset = Choice.objects.all() serializer_class = ChoiceSerializer filter_class = ChoiceFilter @action(methods=["post"], detail=True, permission_classes=[IsAuthenticated]) def vote(self, request, pk=None): """ post: A description of the post method on the custom action. """ choice = self.get_object() choice.vote() serializer = self.get_serializer(choice) return Response(serializer.data) ================================================ FILE: demo/formsbased/polls/apps.py ================================================ from django.apps import AppConfig class PollsConfig(AppConfig): name = 'polls' ================================================ FILE: demo/formsbased/polls/migrations/0001_initial.py ================================================ # Generated by Django 2.2.6 on 2019-11-01 17:08 from django.db import migrations, models import django.db.models.deletion class Migration(migrations.Migration): initial = True dependencies = [ ] operations = [ migrations.CreateModel( name='Question', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('question_text', models.CharField(max_length=200)), ('pub_date', models.DateTimeField(auto_now_add=True, verbose_name='date published')), ], ), migrations.CreateModel( name='Choice', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('choice_text', models.CharField(max_length=200)), ('votes', models.IntegerField(default=0)), ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='polls.Question')), ], ), ] ================================================ FILE: demo/formsbased/polls/migrations/__init__.py ================================================ ================================================ FILE: demo/formsbased/polls/models.py ================================================ import datetime from django.db import models from django.utils import timezone class Question(models.Model): question_text = models.CharField(max_length=200) pub_date = models.DateTimeField('date published', auto_now_add=True) def __str__(self): return self.question_text def was_published_recently(self): now = timezone.now() return now - datetime.timedelta(days=1) <= self.pub_date <= now was_published_recently.admin_order_field = 'pub_date' was_published_recently.boolean = True was_published_recently.short_description = 'Published recently?' class Choice(models.Model): question = models.ForeignKey(Question, on_delete=models.CASCADE) choice_text = models.CharField(max_length=200) votes = models.IntegerField(default=0) def __str__(self): return self.choice_text def vote(self): assert not self._state.adding, "You can't vote on an unsaved choice" self.refresh_from_db() self.votes += 1 self.full_clean() self.save() ================================================ FILE: demo/formsbased/polls/templates/admin/base_site.html ================================================ {% extends "admin/base.html" %} {% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} {% block branding %}

Polls Administration

{% endblock %} {% block nav-global %}{% endblock %} ================================================ FILE: demo/formsbased/polls/templates/polls/detail.html ================================================ {% extends "base.html" %} {% block content %}

{{ question.question_text }}

    {% for choice in question.choice_set.all %}
  • {{ choice.choice_text }} {{ choice.votes }}
  • {% endfor %}
{% endblock %} ================================================ FILE: demo/formsbased/polls/templates/polls/index.html ================================================ {% extends "base.html" %} {% block content %}

Available polls

{% if latest_question_list %}
{% for question in latest_question_list %} {{ question.question_text }} {% endfor %}
{% else %}

No polls are available.

{% endif %} {% endblock %} ================================================ FILE: demo/formsbased/polls/templates/polls/vote.html ================================================ {% extends "base.html" %} {% block content %}

{{ question.question_text }}

{% if error_message %}

{{ error_message }}

{% endif %}
{% csrf_token %} {% for choice in question.choice_set.all %}
{% endfor %}
{% endblock %} ================================================ FILE: demo/formsbased/polls/urls.py ================================================ from django.urls import path from . import views app_name = 'polls' urlpatterns = [ path('', views.IndexView.as_view(), name='index'), path('/', views.DetailView.as_view(), name='detail'), path('/vote/', views.VoteView.as_view(), name='vote'), # path('/savevote/', views.savevote, name='savevote'), ] ================================================ FILE: demo/formsbased/polls/views.py ================================================ from django.contrib.auth.mixins import LoginRequiredMixin from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, render from django.urls import reverse from django.utils import timezone from django.views import generic from .models import Choice, Question class IndexView(generic.ListView): template_name = 'polls/index.html' context_object_name = 'latest_question_list' def get_queryset(self): """ Return the last five published questions (not including those set to be published in the future). """ return Question.objects.filter( pub_date__lte=timezone.now() ).order_by('-pub_date')[:5] class DetailView(generic.DetailView): model = Question template_name = 'polls/detail.html' def get_queryset(self): """ Excludes any questions that aren't published yet. """ return Question.objects.filter(pub_date__lte=timezone.now()) class VoteView(LoginRequiredMixin, generic.DetailView): model = Question template_name = 'polls/vote.html' def get_queryset(self): """ Excludes any questions that aren't published yet. """ return Question.objects.filter(pub_date__lte=timezone.now()) def post(self, request, pk, *args, **kwargs): question = get_object_or_404(Question, pk=pk) try: selected_choice = question.choice_set.get(pk=request.POST['choice']) except (KeyError, Choice.DoesNotExist): # Redisplay the question voting form. return render(request, 'polls/vote.html', { 'question': question, 'error_message': "You didn't select a choice.", }) else: selected_choice.vote() # Always return an HttpResponseRedirect after successfully dealing # with POST data. This prevents data from being posted twice if a # user hits the Back button. return HttpResponseRedirect(reverse('polls:detail', args=(question.id,))) ================================================ FILE: demo/formsbased/templates/base.html ================================================ {% load static %} {% block title %}Polls App{% endblock %}
{% block content %}{% endblock %}
Current User Info
id         = {{ user.id }}
username   = {{ user.username }}
first_name = {{ user.first_name }}
last_name  = {{ user.last_name }}
email      = {{ user.email }}
is_authenticated = {{ user.is_authenticated }}
is_staff         = {{ user.is_staff }}
is_active        = {{ user.is_active }}
is_superuser     = {{ user.is_superuser }}
last_login  = {{ user.last_login }}
date_joined = {{ user.date_joined }}
================================================ FILE: demo/formsbased/templates/home.html ================================================ {% extends 'base.html' %} {% block title %}Home{% endblock %} {% block content %}

Welcome to the polls app

Use the menu above to navigate

{% endblock %} ================================================ FILE: demo/formsbased/templates/registration/logged_out.html ================================================ {% extends 'base.html' %} {% block title %}See you!{% endblock %} {% block content %}

Logged out

You have been successfully logged out.

Log in again.

{% endblock %} ================================================ FILE: demo/formsbased/templates/registration/login.html ================================================ {% extends "base.html" %} {% block content %}

Login

{% if form.errors %}

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

{% endif %} {% if next %} {% if user.is_authenticated %}

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

{% else %}

Please login to see this page.

{% endif %} {% endif %}
{% csrf_token %}
{% endblock %} ================================================ FILE: django_auth_adfs/__init__.py ================================================ """ Don't put imports or code here This file is imported by setup.py Adding imports here will break setup.py """ __version__ = '1.16.0' ================================================ FILE: django_auth_adfs/backend.py ================================================ import logging import jwt from django.contrib.auth import get_user_model from django.contrib.auth.backends import ModelBackend from django.contrib.auth.models import Group from django.core.exceptions import (ImproperlyConfigured, ObjectDoesNotExist, PermissionDenied) from django_auth_adfs import signals from django_auth_adfs.config import provider_config, settings from django_auth_adfs.exceptions import MFARequired logger = logging.getLogger("django_auth_adfs") class AdfsBaseBackend(ModelBackend): def _ms_request(self, action, url, data=None, **kwargs): """ Make a Microsoft Entra/GraphQL request Args: action (callable): The callable for making a request. url (str): The URL the request should be sent to. data (dict): Optional dictionary of data to be sent in the request. Returns: response: The response from the server. If it's not a 200, a PermissionDenied is raised. """ response = action(url, data=data, timeout=settings.TIMEOUT, **kwargs) # 200 = valid token received # 400 = 'something' is wrong in our request if response.status_code == 400: if response.json().get("error_description", "").startswith("AADSTS50076"): raise MFARequired logger.error("ADFS server returned an error: %s", response.json()["error_description"]) raise PermissionDenied if response.status_code != 200: logger.error("Unexpected ADFS response: %s", response.content.decode()) raise PermissionDenied return response def exchange_auth_code(self, authorization_code, request): logger.debug("Received authorization code: %s", authorization_code) data = { 'grant_type': 'authorization_code', 'client_id': settings.CLIENT_ID, 'redirect_uri': provider_config.redirect_uri(request), 'code': authorization_code, } if settings.CLIENT_SECRET: data['client_secret'] = settings.CLIENT_SECRET logger.debug("Getting access token at: %s", provider_config.token_endpoint) response = self._ms_request(provider_config.session.post, provider_config.token_endpoint, data) adfs_response = response.json() return adfs_response def get_obo_access_token(self, access_token): """ Gets an On Behalf Of (OBO) access token, which is required to make queries against MS Graph Args: access_token (str): Original authorization access token from the user Returns: obo_access_token (str): OBO access token that can be used with the MS Graph API """ logger.debug("Getting OBO access token: %s", provider_config.token_endpoint) data = { "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", "client_id": settings.CLIENT_ID, "client_secret": settings.CLIENT_SECRET, "assertion": access_token, "requested_token_use": "on_behalf_of", } if provider_config.token_endpoint.endswith("/v2.0/token"): data["scope"] = 'GroupMember.Read.All' else: data["resource"] = 'https://graph.microsoft.com' response = self._ms_request(provider_config.session.get, provider_config.token_endpoint, data) obo_access_token = response.json()["access_token"] logger.debug("Received OBO access token: %s", obo_access_token) return obo_access_token def get_group_memberships_from_ms_graph_params(self): """ Return the parameters to be used in the querystring when fetching the user's group memberships. Possible keys to be used: - $count - $expand - $filter - $orderby - $search - $select - $top Docs: https://learn.microsoft.com/en-us/graph/api/group-list-transitivememberof?view=graph-rest-1.0&tabs=python#http-request """ return {} def get_group_memberships_from_ms_graph(self, obo_access_token): """ Looks up a users group membership from the MS Graph API Args: obo_access_token (str): Access token obtained from the OBO authorization endpoint Returns: claim_groups (list): List of the users group memberships """ graph_url = "https://{}/v1.0/me/transitiveMemberOf/microsoft.graph.group".format( provider_config.msgraph_endpoint ) headers = {"Authorization": "Bearer {}".format(obo_access_token)} response = self._ms_request( action=provider_config.session.get, url=graph_url, data=self.get_group_memberships_from_ms_graph_params(), headers=headers, ) claim_groups = [] for group_data in response.json()["value"]: if group_data["displayName"] is None: logger.error( "The application does not have the required permission to read user groups from " "MS Graph (GroupMember.Read.All)" ) raise PermissionDenied claim_groups.append(group_data["displayName"]) return claim_groups def validate_access_token(self, access_token): for idx, key in enumerate(provider_config.signing_keys): try: # Explicitly define the verification option. # The list below is the default the jwt module uses. # Explicit is better then implicit and it protects against # changes in the defaults the jwt module uses. options = { 'verify_signature': True, 'verify_exp': True, 'verify_nbf': True, 'verify_iat': True, 'verify_aud': True, 'verify_iss': True, 'require_exp': False, 'require_iat': False, 'require_nbf': False } # Validate token and return claims return jwt.decode( access_token, key=key, algorithms=['RS256', 'RS384', 'RS512'], audience=settings.AUDIENCE, issuer=provider_config.issuer, options=options, leeway=settings.JWT_LEEWAY ) except jwt.ExpiredSignatureError as error: logger.info("Signature has expired: %s", error) raise PermissionDenied except jwt.DecodeError as error: # If it's not the last certificate in the list, skip to the next one if idx < len(provider_config.signing_keys) - 1: continue else: logger.info('Error decoding signature: %s', error) raise PermissionDenied except jwt.InvalidTokenError as error: logger.info(str(error)) raise PermissionDenied def process_access_token(self, access_token, adfs_response=None): if not access_token: raise PermissionDenied logger.debug("Received access token: %s", access_token) claims = self.validate_access_token(access_token) if ( settings.BLOCK_GUEST_USERS and claims.get('tid') != settings.TENANT_ID ): logger.info('Guest user denied') raise PermissionDenied if not claims: raise PermissionDenied groups = self.process_user_groups(claims, access_token) user = self.create_user(claims) self.update_user_attributes(user, claims) self.update_user_groups(user, groups) self.update_user_flags(user, claims, groups) signals.post_authenticate.send( sender=self, user=user, claims=claims, adfs_response=adfs_response ) user.full_clean() user.save() return user def process_user_groups(self, claims, access_token): """ Checks the user groups are in the claim or pulls them from MS Graph if applicable Args: claims (dict): claims from the access token access_token (str): Used to make an OBO authentication request if groups must be obtained from Microsoft Graph Returns: groups (list): Groups the user is a member of, taken from the access token or MS Graph """ groups = [] if settings.GROUPS_CLAIM is None: logger.debug("No group claim has been configured") return groups if settings.GROUPS_CLAIM in claims: groups = claims[settings.GROUPS_CLAIM] if not isinstance(groups, list): groups = [groups, ] elif ( settings.TENANT_ID != "adfs" and "_claim_names" in claims and settings.GROUPS_CLAIM in claims["_claim_names"] ): obo_access_token = self.get_obo_access_token(access_token) groups = self.get_group_memberships_from_ms_graph(obo_access_token) else: logger.debug("The configured groups claim %s was not found in the access token", settings.GROUPS_CLAIM) return groups def create_user(self, claims): """ Create the user if it doesn't exist yet Args: claims (dict): claims from the access token Returns: django.contrib.auth.models.User: A Django user """ # Create the user username_claim = settings.USERNAME_CLAIM guest_username_claim = settings.GUEST_USERNAME_CLAIM usermodel = get_user_model() iss = claims.get('iss') idp = claims.get('idp', iss) if ( guest_username_claim and not claims.get(username_claim) and not settings.BLOCK_GUEST_USERS and (claims.get('tid') != settings.TENANT_ID or iss != idp) ): username_claim = guest_username_claim if not claims.get(username_claim): logger.error("User claim's doesn't have the claim '%s' in his claims: %s" % (username_claim, claims)) raise PermissionDenied userdata = {usermodel.USERNAME_FIELD: claims[username_claim]} try: user = usermodel.objects.get(**userdata) except usermodel.DoesNotExist: if settings.CREATE_NEW_USERS: user = usermodel.objects.create(**userdata) logger.debug("User '%s' has been created.", claims[username_claim]) else: logger.debug("User '%s' doesn't exist and creating users is disabled.", claims[username_claim]) raise PermissionDenied if not user.password: user.set_unusable_password() return user # https://github.com/snok/django-auth-adfs/issues/241 def update_user_attributes(self, user, claims, claim_mapping=None): """ Updates user attributes based on the CLAIM_MAPPING setting. Recursively updates related fields if CLAIM_MAPPING settings has nested dictionaries. Args: user (django.contrib.auth.models.User): User model instance claims (dict): claims from the access token """ if claim_mapping is None: claim_mapping = settings.CLAIM_MAPPING required_fields = [field.name for field in user._meta.get_fields() if getattr(field, 'blank', True) is False] for field, claim in claim_mapping.items(): if hasattr(user, field) or user._meta.fields_map.get(field): if not isinstance(claim, dict): if claim in claims: setattr(user, field, claims[claim]) logger.debug("Attribute '%s' for instance '%s' was set to '%s'.", field, user, claims[claim]) else: if field in required_fields: msg = "Claim not found in access token: '{}'. Check ADFS claims mapping." raise ImproperlyConfigured(msg.format(claim)) else: logger.warning("Claim '%s' for field '%s' was not found in " "the access token for instance '%s'. " "Field is not required and will be left empty", claim, field, user) else: try: self.update_user_attributes(getattr(user, field), claims, claim_mapping=claim) except ObjectDoesNotExist: logger.warning("Object for field '{}' does not exist for: '{}'.".format(field, user)) else: msg = "Model '{}' has no field named '{}'. Check ADFS claims mapping." raise ImproperlyConfigured(msg.format(user._meta.model_name, field)) def update_user_groups(self, user, claim_groups): """ Updates user group memberships based on the GROUPS_CLAIM setting. Args: user (django.contrib.auth.models.User): User model instance claim_groups (list): User groups from the access token / MS Graph """ if settings.GROUPS_CLAIM is not None: # Update the user's group memberships user_group_names = user.groups.all().values_list("name", flat=True) if sorted(claim_groups) != sorted(user_group_names): # Get the list of already existing groups in one SQL query existing_claimed_groups = Group.objects.filter(name__in=claim_groups) if settings.MIRROR_GROUPS: existing_claimed_group_names = ( group.name for group in existing_claimed_groups ) # One SQL query by created group. # bulk_create could have been used here but we want to send signals. new_claimed_groups = [ Group.objects.get_or_create(name=name)[0] for name in claim_groups if name not in existing_claimed_group_names ] # Associate the users to all claimed groups user.groups.set(tuple(existing_claimed_groups) + tuple(new_claimed_groups)) else: # Associate the user to only existing claimed groups user.groups.set(existing_claimed_groups) def update_user_flags(self, user, claims, claim_groups): """ Updates user boolean attributes based on the BOOLEAN_CLAIM_MAPPING setting. Args: user (django.contrib.auth.models.User): User model instance claims (dict): Claims from the access token claim_groups (list): User groups from the access token / MS Graph """ if settings.GROUPS_CLAIM is not None: for flag, group in settings.GROUP_TO_FLAG_MAPPING.items(): if hasattr(user, flag): if not isinstance(group, list): group = [group] if any(group_list_item in claim_groups for group_list_item in group): value = True else: value = False setattr(user, flag, value) logger.debug("Attribute '%s' for user '%s' was set to '%s'.", flag, user, value) else: msg = "User model has no field named '{}'. Check ADFS boolean claims mapping." raise ImproperlyConfigured(msg.format(flag)) for field, claim in settings.BOOLEAN_CLAIM_MAPPING.items(): if hasattr(user, field): bool_val = False if claim in claims and str(claims[claim]).lower() in ['y', 'yes', 't', 'true', 'on', '1']: bool_val = True setattr(user, field, bool_val) logger.debug("Attribute '%s' for user '%s' was set to '%s'.", field, user, bool_val) else: msg = "User model has no field named '{}'. Check ADFS boolean claims mapping." raise ImproperlyConfigured(msg.format(field)) class AdfsAuthCodeBackend(AdfsBaseBackend): """ Authentication backend to allow authenticating users against a Microsoft ADFS server with an authorization code. """ def authenticate(self, request=None, authorization_code=None, **kwargs): # If there's no token or code, we pass control to the next authentication backend if authorization_code is None or authorization_code == '': logger.debug("Authentication backend was called but no authorization code was received") return # If loaded data is too old, reload it again provider_config.load_config() adfs_response = self.exchange_auth_code(authorization_code, request) access_token = adfs_response["access_token"] user = self.process_access_token(access_token, adfs_response) return user class AdfsAccessTokenBackend(AdfsBaseBackend): """ Authentication backend to allow authenticating users against a Microsoft ADFS server with an access token retrieved by the client. """ def authenticate(self, request=None, access_token=None, **kwargs): # If loaded data is too old, reload it again provider_config.load_config() # If there's no token or code, we pass control to the next authentication backend if access_token is None or access_token == '': logger.debug("Authentication backend was called but no access token was received") return access_token = access_token.decode() user = self.process_access_token(access_token) return user class AdfsBackend(AdfsAuthCodeBackend): """ Backwards compatible class name """ pass ================================================ FILE: django_auth_adfs/config.py ================================================ import base64 import logging import warnings from datetime import datetime, timedelta from xml.etree import ElementTree import requests import requests.adapters from urllib3.util.retry import Retry from cryptography.hazmat.backends.openssl.backend import backend from cryptography.x509 import load_der_x509_certificate from django.conf import settings as django_settings from django.contrib.auth import REDIRECT_FIELD_NAME from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured from django.http import QueryDict from django.shortcuts import render from django.utils.module_loading import import_string try: from django.urls import reverse except ImportError: # Django < 1.10 from django.core.urlresolvers import reverse logger = logging.getLogger("django_auth_adfs") AZURE_AD_SERVER = "login.microsoftonline.com" DEFAULT_SETTINGS_CLASS = 'django_auth_adfs.config.Settings' class ConfigLoadError(Exception): pass def _get_settings_class(): """ Get the AUTH_ADFS setting from the Django settings. """ if not hasattr(django_settings, "AUTH_ADFS"): msg = "The configuration directive 'AUTH_ADFS' was not found in your Django settings" raise ImproperlyConfigured(msg) cls = django_settings.AUTH_ADFS.get('SETTINGS_CLASS', DEFAULT_SETTINGS_CLASS) return import_string(cls) class Settings(object): """ Settings implementation reading from the Django settings. """ def __init__(self): # Set defaults self.AUDIENCE = None # Required self.BLOCK_GUEST_USERS = False self.BOOLEAN_CLAIM_MAPPING = {} self.CA_BUNDLE = True self.CLAIM_MAPPING = {} self.CLIENT_ID = None # Required self.CLIENT_SECRET = None self.CONFIG_RELOAD_INTERVAL = 24 # hours self.CREATE_NEW_USERS = True self.DISABLE_SSO = False self.GROUP_TO_FLAG_MAPPING = {} self.GROUPS_CLAIM = "group" self.LOGIN_EXEMPT_URLS = [] self.MIRROR_GROUPS = False self.RELYING_PARTY_ID = None # Required self.RETRIES = 3 self.SERVER = None # Required self.TENANT_ID = None # Required self.TIMEOUT = 5 self.USERNAME_CLAIM = "winaccountname" self.GUEST_USERNAME_CLAIM = None self.JWT_LEEWAY = 0 self.CUSTOM_FAILED_RESPONSE_VIEW = lambda request, error_message, status: render( request, 'django_auth_adfs/login_failed.html', {'error_message': error_message}, status=status ) self.PROXIES = None self.VERSION = 'v1.0' self.SCOPES = [] required_settings = [ "AUDIENCE", "CLIENT_ID", "RELYING_PARTY_ID", "USERNAME_CLAIM", ] deprecated_settings = { "AUTHORIZE_PATH": "This setting is automatically loaded from ADFS.", "ISSUER": "This setting is automatically loaded from ADFS.", "LOGIN_REDIRECT_URL": "Instead use the standard Django settings with the same name.", "REDIR_URI": "This setting is automatically determined based on the URL configuration of Django.", "SIGNING_CERT": "The token signing certificates are automatically loaded from ADFS.", "TOKEN_PATH": "This setting is automatically loaded from ADFS.", } if not hasattr(django_settings, "AUTH_ADFS"): msg = "The configuration directive 'AUTH_ADFS' was not found in your Django settings" raise ImproperlyConfigured(msg) _settings = django_settings.AUTH_ADFS # Settings class is loaded by now. Delete this setting if "SETTINGS_CLASS" in _settings: del _settings["SETTINGS_CLASS"] # Handle deprecated settings for setting, message in deprecated_settings.items(): if setting in _settings: warnings.warn("Setting {} is deprecated and it's value was ignored. {}".format(setting, message), DeprecationWarning) del _settings[setting] if "CERT_MAX_AGE" in _settings: _settings["CONFIG_RELOAD_INTERVAL"] = _settings["CERT_MAX_AGE"] warnings.warn('Setting CERT_MAX_AGE has been renamed to CONFIG_RELOAD_INTERVAL. The value was copied.', DeprecationWarning) del _settings["CERT_MAX_AGE"] if "GROUP_CLAIM" in _settings: _settings["GROUPS_CLAIM"] = _settings["GROUP_CLAIM"] warnings.warn('Setting GROUP_CLAIM has been renamed to GROUPS_CLAIM. The value was copied.', DeprecationWarning) del _settings["GROUP_CLAIM"] if "RESOURCE" in _settings: _settings["RELYING_PARTY_ID"] = _settings["RESOURCE"] del _settings["RESOURCE"] if "TENANT_ID" in _settings: # If a tenant ID was set, switch to Azure AD mode if "SERVER" in _settings: raise ImproperlyConfigured("The SERVER cannot be set when TENANT_ID is set.") self.SERVER = AZURE_AD_SERVER self.USERNAME_CLAIM = "upn" self.GROUPS_CLAIM = "groups" self.CLAIM_MAPPING = {"first_name": "given_name", "last_name": "family_name", "email": "email"} elif "VERSION" in _settings: raise ImproperlyConfigured("The VERSION cannot be set when TENANT_ID is not set.") if self.VERSION == "v2.0" and not self.SCOPES and self.RELYING_PARTY_ID: warnings.warn('Use `SCOPES` for AzureAD instead of RELYING_PARTY_ID', DeprecationWarning) if not isinstance(self.SCOPES, list): raise ImproperlyConfigured("Scopes must be a list") # Overwrite defaults with user settings for setting, value in _settings.items(): if hasattr(self, setting): setattr(self, setting, value) else: msg = "'{0}' is not a valid configuration directive for django_auth_adfs." raise ImproperlyConfigured(msg.format(setting)) if self.SERVER != AZURE_AD_SERVER and self.BLOCK_GUEST_USERS: raise ImproperlyConfigured("You can only set BLOCK_GUEST_USERS when self.TENANT_ID is set") if self.TENANT_ID is None: # For on premises ADFS, the tenant ID is set to adfs # On AzureAD the adfs part in the URL happens to be replace by the tenant ID. self.TENANT_ID = "adfs" for setting in required_settings: if not getattr(self, setting): msg = "django_auth_adfs setting '{0}' has not been set".format(setting) raise ImproperlyConfigured(msg) # Setup dynamic settings if not callable(self.CUSTOM_FAILED_RESPONSE_VIEW): self.CUSTOM_FAILED_RESPONSE_VIEW = import_string(self.CUSTOM_FAILED_RESPONSE_VIEW) # Validate setting conflicts usermodel = get_user_model() if usermodel.USERNAME_FIELD in self.CLAIM_MAPPING: raise ImproperlyConfigured("You cannot set the username field of the user model from " "the CLAIM_MAPPING setting. Instead use the USERNAME_CLAIM setting.") class ProviderConfig(object): def __init__(self): self._config_timestamp = None self._mode = None self.authorization_endpoint = None self.signing_keys = None self.token_endpoint = None self.end_session_endpoint = None self.issuer = None self.msgraph_endpoint = None allowed_methods = frozenset([ 'HEAD', 'GET', 'PUT', 'DELETE', 'OPTIONS', 'TRACE', 'POST' ]) retry = Retry( total=settings.RETRIES, read=settings.RETRIES, connect=settings.RETRIES, backoff_factor=0.3, allowed_methods=allowed_methods ) self.session = requests.Session() adapter = requests.adapters.HTTPAdapter(max_retries=retry) self.session.mount('https://', adapter) self.session.verify = settings.CA_BUNDLE if hasattr(settings, "PROXIES"): self.session.proxies = settings.PROXIES def load_config(self): # If loaded data is too old, reload it again refresh_time = datetime.now() - timedelta(hours=settings.CONFIG_RELOAD_INTERVAL) if self._config_timestamp is None or self._config_timestamp < refresh_time: logger.debug("Loading ID Provider configuration.") try: loaded = self._load_openid_config() self._mode = "openid_connect" except ConfigLoadError: loaded = self._load_federation_metadata() self._mode = "oauth2" if not loaded: if self._config_timestamp is None: msg = "Could not load any data from ADFS server. " \ "Authentication against ADFS not be possible. " \ "Verify your settings and the connection with the ADFS server." logger.critical(msg) raise RuntimeError(msg) else: # We got data from the previous time. Log a message, but don't abort. logger.warning("Could not load any data from ADFS server. Keeping previous configurations") self._config_timestamp = datetime.now() logger.info("Loaded settings from ADFS server.") logger.info("operating mode: %s", self._mode) logger.info("authorization endpoint: %s", self.authorization_endpoint) logger.info("token endpoint: %s", self.token_endpoint) logger.info("end session endpoint: %s", self.end_session_endpoint) logger.info("issuer: %s", self.issuer) logger.info("msgraph endpoint: %s", self.msgraph_endpoint) def _load_openid_config(self): if settings.VERSION != 'v1.0': config_url = "https://{}/{}/{}/.well-known/openid-configuration?appid={}".format( settings.SERVER, settings.TENANT_ID, settings.VERSION, settings.CLIENT_ID ) else: config_url = "https://{}/{}/.well-known/openid-configuration?appid={}".format( settings.SERVER, settings.TENANT_ID, settings.CLIENT_ID ) try: logger.info("Trying to get OpenID Connect config from %s", config_url) response = self.session.get(config_url, timeout=settings.TIMEOUT) response.raise_for_status() openid_cfg = response.json() response = self.session.get(openid_cfg["jwks_uri"], timeout=settings.TIMEOUT) response.raise_for_status() signing_certificates = [x["x5c"][0] for x in response.json()["keys"] if x.get("use", "sig") == "sig"] # ^^^ # https://tools.ietf.org/html/draft-ietf-jose-json-web-key-41#section-4.7 # The PKIX certificate containing the key value MUST be the first certificate except requests.HTTPError: raise ConfigLoadError self._load_keys(signing_certificates) try: self.authorization_endpoint = openid_cfg["authorization_endpoint"] self.token_endpoint = openid_cfg["token_endpoint"] self.end_session_endpoint = openid_cfg["end_session_endpoint"] if settings.TENANT_ID != 'adfs': self.issuer = openid_cfg["issuer"] self.msgraph_endpoint = openid_cfg["msgraph_host"] else: self.issuer = openid_cfg["access_token_issuer"] self.msgraph_endpoint = "graph.microsoft.com" except KeyError: raise ConfigLoadError return True def _load_federation_metadata(self): server_url = "https://{}".format(settings.SERVER) base_url = "{}/{}".format(server_url, settings.TENANT_ID) if settings.TENANT_ID == "adfs": adfs_config_url = server_url + "/FederationMetadata/2007-06/FederationMetadata.xml" else: adfs_config_url = base_url + "/FederationMetadata/2007-06/FederationMetadata.xml" try: logger.info("Trying to get ADFS Metadata file %s", adfs_config_url) response = self.session.get(adfs_config_url, timeout=settings.TIMEOUT) response.raise_for_status() except requests.HTTPError: raise ConfigLoadError # Extract token signing certificates xml_tree = ElementTree.fromstring(response.content) cert_nodes = xml_tree.findall( "./{urn:oasis:names:tc:SAML:2.0:metadata}RoleDescriptor" "[@{http://www.w3.org/2001/XMLSchema-instance}type='fed:SecurityTokenServiceType']" "/{urn:oasis:names:tc:SAML:2.0:metadata}KeyDescriptor[@use='signing']" "/{http://www.w3.org/2000/09/xmldsig#}KeyInfo" "/{http://www.w3.org/2000/09/xmldsig#}X509Data" "/{http://www.w3.org/2000/09/xmldsig#}X509Certificate") signing_certificates = [node.text for node in cert_nodes] self._load_keys(signing_certificates) self.issuer = xml_tree.get("entityID") self.authorization_endpoint = base_url + "/oauth2/authorize" self.token_endpoint = base_url + "/oauth2/token" self.end_session_endpoint = base_url + "/ls/?wa=wsignout1.0" self.msgraph_endpoint = "graph.microsoft.com" return True def _load_keys(self, certificates): new_keys = [] for cert in certificates: logger.debug("Loading public key from certificate: %s", cert) cert_obj = load_der_x509_certificate(base64.b64decode(cert), backend) new_keys.append(cert_obj.public_key()) self.signing_keys = new_keys def redirect_uri(self, request): self.load_config() return request.build_absolute_uri(reverse("django_auth_adfs:callback")) def build_authorization_endpoint(self, request, disable_sso=None, force_mfa=False): """ This function returns the ADFS authorization URL. Args: request(django.http.request.HttpRequest): A django Request object disable_sso(bool): Whether to disable single sign-on and force the ADFS server to show a login prompt. force_mfa(bool): If MFA should be forced Returns: str: The redirect URI """ self.load_config() if request.method == 'POST': redirect_to = request.POST.get(REDIRECT_FIELD_NAME, None) else: redirect_to = request.GET.get(REDIRECT_FIELD_NAME, None) if not redirect_to: redirect_to = django_settings.LOGIN_REDIRECT_URL redirect_to = base64.urlsafe_b64encode(redirect_to.encode()).decode() query = QueryDict(mutable=True) query.update({ "response_type": "code", "client_id": settings.CLIENT_ID, "resource": settings.RELYING_PARTY_ID, "redirect_uri": self.redirect_uri(request), "state": redirect_to, }) if self._mode == "openid_connect": if settings.VERSION == 'v2.0': if settings.SCOPES: query['scope'] = " ".join(settings.SCOPES) else: query["scope"] = f"openid api://{settings.RELYING_PARTY_ID}/.default" query.pop("resource") else: query["scope"] = "openid" if (disable_sso is None and settings.DISABLE_SSO) or disable_sso is True: query["prompt"] = "login" if force_mfa: query["amr_values"] = "ngcmfa" return "{0}?{1}".format(self.authorization_endpoint, query.urlencode()) def build_end_session_endpoint(self): """ This function returns the ADFS end session URL to log a user out. Returns: str: The redirect URI """ self.load_config() return self.end_session_endpoint settings_cls = _get_settings_class() settings = settings_cls() provider_config = ProviderConfig() ================================================ FILE: django_auth_adfs/drf-urls.py ================================================ # flake8: noqa import warnings from .drf_urls import * warnings.warn( "drf-urls.py is not a valid module name and will be " "removed in a future version, use drf_urls.py instead", PendingDeprecationWarning ) ================================================ FILE: django_auth_adfs/drf_urls.py ================================================ """ These URL patterns are used to override the default Django Rest Framework login page. It's a bit of a hack, but DRF doesn't support overriding the login URL. """ from django.urls import re_path from django_auth_adfs import views app_name = "rest_framework" urlpatterns = [ re_path(r'^login$', views.OAuth2LoginView.as_view(), name='login'), re_path(r'^logout$', views.OAuth2LogoutView.as_view(), name='logout'), ] ================================================ FILE: django_auth_adfs/exceptions.py ================================================ class MFARequired(Exception): """ Exception to indicate that a MFA auth is required. """ pass ================================================ FILE: django_auth_adfs/middleware.py ================================================ """ Based on https://djangosnippets.org/snippets/1179/ """ from re import compile from django.conf import settings as django_settings from django.contrib.auth.views import redirect_to_login from django.urls import reverse from django_auth_adfs.exceptions import MFARequired from django_auth_adfs.config import settings LOGIN_EXEMPT_URLS = [ compile(django_settings.LOGIN_URL.lstrip('/')), compile(reverse("django_auth_adfs:login").lstrip('/')), compile(reverse("django_auth_adfs:logout").lstrip('/')), compile(reverse("django_auth_adfs:callback").lstrip('/')), ] if hasattr(settings, 'LOGIN_EXEMPT_URLS'): LOGIN_EXEMPT_URLS += [compile(expr) for expr in settings.LOGIN_EXEMPT_URLS] class LoginRequiredMiddleware: """ Middleware that requires a user to be authenticated to view any page other than LOGIN_URL. Exemptions to this requirement can optionally be specified in settings via a list of regular expressions in LOGIN_EXEMPT_URLS (which you can copy from your urls.py). Requires authentication middleware and template context processors to be loaded. You'll get an error if they aren't. """ def __init__(self, get_response): self.get_response = get_response def __call__(self, request): assert hasattr(request, 'user'), "The Login Required middleware requires " \ "authentication middleware to be installed. " \ "Edit your MIDDLEWARE setting to insert " \ "'django.contrib.auth.middleware.AuthenticationMiddleware'. " \ "If that doesn't work, ensure your TEMPLATE_CONTEXT_PROCESSORS " \ "setting includes 'django.core.context_processors.auth'." if not request.user.is_authenticated: path = request.path_info.lstrip('/') if not any(m.match(path) for m in LOGIN_EXEMPT_URLS): try: return redirect_to_login(request.get_full_path()) except MFARequired: return redirect_to_login('django_auth_adfs:login-force-mfa') return self.get_response(request) ================================================ FILE: django_auth_adfs/rest_framework.py ================================================ from __future__ import absolute_import from django.contrib.auth import authenticate from rest_framework import exceptions from rest_framework.authentication import ( BaseAuthentication, get_authorization_header ) from django_auth_adfs.exceptions import MFARequired class AdfsAccessTokenAuthentication(BaseAuthentication): """ ADFS access Token authentication """ www_authenticate_realm = 'api' def authenticate(self, request): """ Returns a `User` if a correct access token has been supplied in the Authorization header. Otherwise returns `None`. """ auth = get_authorization_header(request).split() if not auth or auth[0].lower() != b'bearer': return None if len(auth) == 1: msg = 'Invalid authorization header. No credentials provided.' raise exceptions.AuthenticationFailed(msg) elif len(auth) > 2: msg = 'Invalid authorization header. Access token should not contain spaces.' raise exceptions.AuthenticationFailed(msg) # Authenticate the user # The AdfsAuthCodeBackend authentication backend will notice the "access_token" parameter # and skip the request for an access token using the authorization code try: user = authenticate(access_token=auth[1]) except MFARequired as e: raise exceptions.AuthenticationFailed('MFA auth is required.') from e if user is None: raise exceptions.AuthenticationFailed('Invalid access token.') if not user.is_active: raise exceptions.AuthenticationFailed('User inactive or deleted.') return user, auth[1] def authenticate_header(self, request): return 'Bearer realm="%s" token_type="JWT"' % self.www_authenticate_realm ================================================ FILE: django_auth_adfs/signals.py ================================================ from django.dispatch import Signal # Arguments sent with the signal: # * user # * claims # * adfs_response post_authenticate = Signal() ================================================ FILE: django_auth_adfs/templates/django_auth_adfs/login_failed.html ================================================ Login Failure
{{ error_message }}
================================================ FILE: django_auth_adfs/urls.py ================================================ from django.urls import re_path from django_auth_adfs import views app_name = "django_auth_adfs" urlpatterns = [ re_path(r'^callback$', views.OAuth2CallbackView.as_view(), name='callback'), re_path(r'^login$', views.OAuth2LoginView.as_view(), name='login'), re_path(r'^login_no_sso$', views.OAuth2LoginNoSSOView.as_view(), name='login-no-sso'), re_path(r'^login_force_mfa$', views.OAuth2LoginForceMFA.as_view(), name='login-force-mfa'), re_path(r'^logout$', views.OAuth2LogoutView.as_view(), name='logout'), ] ================================================ FILE: django_auth_adfs/views.py ================================================ import base64 import logging from django.conf import settings as django_settings from django.contrib.auth import authenticate, login, logout from django.shortcuts import redirect try: from django.utils.http import url_has_allowed_host_and_scheme except ImportError: # Django <3.0 from django.utils.http import is_safe_url as url_has_allowed_host_and_scheme from django.views.generic import View from django_auth_adfs.config import provider_config, settings from django_auth_adfs.exceptions import MFARequired logger = logging.getLogger("django_auth_adfs") class OAuth2CallbackView(View): def get(self, request): """ Handles the redirect from ADFS to our site. We try to process the passed authorization code and login the user. Args: request (django.http.request.HttpRequest): A Django Request object """ code = request.GET.get("code") if not code: # Return an error message return settings.CUSTOM_FAILED_RESPONSE_VIEW( request, error_message="No authorization code was provided.", status=400 ) redirect_to = request.GET.get("state") try: user = authenticate(request=request, authorization_code=code) except MFARequired: return redirect(provider_config.build_authorization_endpoint(request, force_mfa=True)) if user: if user.is_active: login(request, user) # Redirect to the "after login" page. # Because we got redirected from ADFS, we can't know where the # user came from. if redirect_to: redirect_to = base64.urlsafe_b64decode(redirect_to.encode()).decode() else: redirect_to = django_settings.LOGIN_REDIRECT_URL url_is_safe = url_has_allowed_host_and_scheme( url=redirect_to, allowed_hosts=[request.get_host()], require_https=request.is_secure(), ) redirect_to = redirect_to if url_is_safe else '/' return redirect(redirect_to) else: # Return a 'disabled account' error message return settings.CUSTOM_FAILED_RESPONSE_VIEW( request, error_message="Your account is disabled.", status=403 ) else: # Return an 'invalid login' error message return settings.CUSTOM_FAILED_RESPONSE_VIEW( request, error_message="Login failed.", status=401 ) class OAuth2LoginView(View): def get(self, request): """ Initiates the OAuth2 flow and redirect the user agent to ADFS Args: request (django.http.request.HttpRequest): A Django Request object """ return redirect(provider_config.build_authorization_endpoint(request)) def post(self, request): """ Initiates the OAuth2 flow and redirect the user agent to ADFS Args: request (django.http.request.HttpRequest): A Django Request object """ return redirect(provider_config.build_authorization_endpoint(request)) class OAuth2LoginNoSSOView(View): def get(self, request): """ Initiates the OAuth2 flow and redirect the user agent to ADFS Args: request (django.http.request.HttpRequest): A Django Request object """ return redirect(provider_config.build_authorization_endpoint(request, disable_sso=True)) def post(self, request): """ Initiates the OAuth2 flow and redirect the user agent to ADFS Args: request (django.http.request.HttpRequest): A Django Request object """ return redirect(provider_config.build_authorization_endpoint(request, disable_sso=True)) class OAuth2LoginForceMFA(View): def get(self, request): """ Initiates the OAuth2 flow and redirect the user agent to ADFS Args: request (django.http.request.HttpRequest): A Django Request object """ return redirect(provider_config.build_authorization_endpoint(request, force_mfa=True)) def post(self, request): """ Initiates the OAuth2 flow and redirect the user agent to ADFS Args: request (django.http.request.HttpRequest): A Django Request object """ return redirect(provider_config.build_authorization_endpoint(request, force_mfa=True)) class OAuth2LogoutView(View): def get(self, request): """ Logs out the user from both Django and ADFS Args: request (django.http.request.HttpRequest): A Django Request object """ logout(request) return redirect(provider_config.build_end_session_endpoint()) def post(self, request): """ Logs out the user from both Django and ADFS Args: request (django.http.request.HttpRequest): A Django Request object """ logout(request) return redirect(provider_config.build_end_session_endpoint()) ================================================ FILE: docs/Makefile ================================================ # Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) $(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/) endif # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " applehelp to make an Apple Help Book" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" @echo " coverage to run coverage check of the documentation (if enabled)" .PHONY: clean clean: rm -rf $(BUILDDIR)/* .PHONY: html html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." .PHONY: dirhtml dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." .PHONY: singlehtml singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." .PHONY: pickle pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." .PHONY: json json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." .PHONY: htmlhelp htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." .PHONY: qthelp qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-auth-adfs.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-auth-adfs.qhc" .PHONY: applehelp applehelp: $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp @echo @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." @echo "N.B. You won't be able to view it unless you put it in" \ "~/Library/Documentation/Help or install it in your application" \ "bundle." .PHONY: devhelp devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/django-auth-adfs" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-auth-adfs" @echo "# devhelp" .PHONY: epub epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." .PHONY: latex latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." .PHONY: latexpdf latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." .PHONY: latexpdfja latexpdfja: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through platex and dvipdfmx..." $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." .PHONY: text text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." .PHONY: man man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." .PHONY: texinfo texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." .PHONY: info info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." .PHONY: gettext gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." .PHONY: changes changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." .PHONY: linkcheck linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." .PHONY: doctest doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." .PHONY: coverage coverage: $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage @echo "Testing of coverage in the sources finished, look at the " \ "results in $(BUILDDIR)/coverage/python.txt." .PHONY: xml xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." .PHONY: pseudoxml pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." ================================================ FILE: docs/_templates/.gitkeep ================================================ ================================================ FILE: docs/adfs_3.0_config_guide.rst ================================================ Windows 2012 R2 - ADFS 3.0 ========================== Getting this module to work is sometimes not so straight forward. If your not familiar with JWT tokens or ADFS itself, it might take some tries to get all settings right. This guide tries to give a basic overview of how to configure ADFS and how to determine the settings for django-auth-adfs. Installing and configuring the basics of ADFS is not explained here. * **ADFS server:** https://adfs.example.com * **Web server:** http://web.example.com:8000 Step 1 - Configuring a Relying Party Trust ------------------------------------------ From the AD FS Management screen, go to **AD FS ➜ Trust Relationships ➜ Relying Party Trusts** and click **Add Relying Party Trust...** .. image:: _static/2012/01_add_relying_party.png :scale: 50 % ------------ Click **Start** .. image:: _static/2012/02_add_relying_party_wizard_page1.png :scale: 50 % ------------ Select **Enter data about the relying party manually** and click **Next** .. image:: _static/2012/03_add_relying_party_wizard_page2.png :scale: 50 % ------------ Enter a display name for the relying party and click **Next**. .. image:: _static/2012/04_add_relying_party_wizard_page3.png :scale: 50 % ------------ Select **AD FS profile** and click **Next** .. image:: _static/2012/05_add_relying_party_wizard_page4.png :scale: 50 % ------------ Leave everything empty click **Next** .. image:: _static/2012/06_add_relying_party_wizard_page5.png :scale: 50 % ------------ We don't need WS-Federation or SAML support so leave everything empty and click **Next** .. image:: _static/2012/07_add_relying_party_wizard_page6.png :scale: 50 % ------------ Enter a relying party trust identifier and click **add**. The identifier can be anything but beware, there's a difference between entering a URL and something else. For more details see the example section of :ref:`the AUDIENCE setting `. .. note:: This is the value for the :ref:`audience_setting` and the :ref:`relying_party_id_setting` settings. .. image:: _static/2012/08_relying_party_id.png :scale: 50 % ------------ Select **I do not want to configure...** and click **Next**. .. image:: _static/2012/09_add_relying_party_wizard_page8.png :scale: 50 % ------------ Select **Permit all users to access the relying party** and click **Next**. .. image:: _static/2012/10_add_relying_party_wizard_page9.png :scale: 50 % ------------ Review the settings and click **Next** to create the relying party. .. image:: _static/2012/11_add_relying_party_wizard_review.png :scale: 50 % ------------ Check **Open the Edit Claim Rules dialog...** and click **Close** .. image:: _static/2012/12_add_relying_party_wizard_page11.png :scale: 50 % Step 2 - Configuring Claims --------------------------- If you selected **Open the Edit Claim Rules dialog...** while adding a relying party, this screen will open automatically. Else you can open it by right clicking the relying party in the list and select **Edit Claim Rules...** On the **Issuance Transform Rules** tab, click the **Add Rule** button .. image:: _static/2012/13_configure_claims_page1.png :scale: 50 % ------------ Select **Send LDAP Attributes as Claims** and click **Next** .. image:: _static/2012/14_configure_claims_page2.png :scale: 50 % ------------ Give the rule a name and select **Active Directory** as the attribute store. Then configure the below claims. +----------------------------------+----------------------+ | LDAP Attribute | Outgoing Claim Type | +==================================+======================+ | E-Mail-Addresses | E-Mail Address | +----------------------------------+----------------------+ | Given-Name | Given Name | +----------------------------------+----------------------+ | Surname | Surname | +----------------------------------+----------------------+ | Token-Groups - Unqualified Names | Group | +----------------------------------+----------------------+ | SAM-Account-Name | Windows Account Name | +----------------------------------+----------------------+ .. image:: _static/2012/15_configure_claims_page3.png :scale: 50 % Click **OK** to save the settings .. note:: The **Outgoing Claim Type** is what will be visible in the JWT Access Token. The first 3 claims will go into the :ref:`claim_mapping_setting` setting. The 4th is the :ref:`groups_claim_setting` setting. The 5th is the :ref:`username_claim_setting` setting. You cannot just copy the outgoing claim type value from this screen and use it in the settings. The name of the claim as it is in the JWT token is the short name which you can find in the AD FS Management screen underneath **AD FS ➜ Service ➜ Claim Descriptions** ------------ You should now see the rule added. Click **OK** to save the settings. .. image:: _static/2012/16_configure_claims_page4.png :scale: 50 % Step 3 - Add an ADFS client --------------------------- While the previous steps could be done via the GUI, the next step must be performed via PowerShell. Pick a value for the following fields. +-------------+----------------------------------------------+ | Name | Example value | +=============+==============================================+ | Name | Django Application OAuth2 Client | +-------------+----------------------------------------------+ | ClientId | 487d8ff7-80a8-4f62-b926-c2852ab06e94 | +-------------+----------------------------------------------+ | RedirectUri | http://web.example.com/oauth2/callback | +-------------+----------------------------------------------+ Now execute the following command from a powershell console. .. code-block:: ps1con PS C:\Users\Administrator> Add-ADFSClient -Name "Django Application OAuth2 Client" ` -ClientId "487d8ff7-80a8-4f62-b926-c2852ab06e94" ` -RedirectUri "http://web.example.com/oauth2/callback" The **ClientId** value will be the :ref:`client_id_setting` setting and the **RedirectUri** value is based on where you added the ```django_auth_adfs`` in your ``urls.py`` file. Step 4 - Determine configuration settings ----------------------------------------- Once everything is configured, you can use the below PowerShell commands to determine the value for the settings of this package. The ``<<<<<<`` in the output indicate which settings should match this value. .. code-block:: ps1con PS C:\Users\Administrator> Get-AdfsClient -Name "Django Application OAuth2 Client" RedirectUri : {http://web.example.com:8000/oauth2/callback} Name : Django Application OAuth2 Client Description : ClientId : 487d8ff7-80a8-4f62-b926-c2852ab06e94 <<< CLIENT_ID <<< BuiltIn : False Enabled : True ClientType : Public PS C:\Users\Administrator> Get-AdfsProperties | select HostName | Format-List HostName : adfs.example.com <<< SERVER <<< PS C:\Users\Administrator> Get-AdfsRelyingPartyTrust -Name "Django Application" | Select Identifier | Format-List Identifier : {web.example.com} <<< RELYING_PARTY_ID and AUDIENCE <<< If you followed this guide, you should end up with a configuration like this. .. code-block:: python AUTH_ADFS = { "SERVER": "adfs.example.com", "CLIENT_ID": "487d8ff7-80a8-4f62-b926-c2852ab06e94 ", "RELYING_PARTY_ID": "web.example.com", "AUDIENCE": "microsoft:identityserver:web.example.com", "CLAIM_MAPPING": {"first_name": "given_name", "last_name": "family_name", "email": "email"}, "USERNAME_CLAIM": "winaccountname", "GROUP_CLAIM": "group" } Enabling SSO for other browsers ------------------------------- By default, ADFS only supports seamless single sign-on for Internet Explorer. In other browsers, users will always be prompted for their username and password. To enable SSO also for other browsers like Chrome and Firefox, execute the following PowerShell command: .. code-block:: powershell [System.Collections.ArrayList]$UserAgents = Get-AdfsProperties | select -ExpandProperty WIASupportedUserAgents $UserAgents.Add("Mozilla/5.0") Set-ADFSProperties -WIASupportedUserAgents $UserAgents After that, restart the ADFS service on every server in the ADFS farm. For firefox, you'll also have to change it's ``network.automatic-ntlm-auth.trusted-uris`` setting to include the URI of your ADFS server. ================================================ FILE: docs/adfs_4.0_config_guide.rst ================================================ Windows 2016 - ADFS 4.0 ======================= Getting this module to work is sometimes not so straight forward. If your not familiar with JWT tokens or ADFS itself, it might take some tries to get all settings right. This guide tries to give a basic overview of how to configure ADFS and how to determine the settings for django-auth-adfs. Installing and configuring the basics of ADFS is not explained here. * **ADFS server:** https://adfs.example.com * **Web server:** http://web.example.com:8000 Step 1 - Configuring an Application Group ----------------------------------------- From the AD FS Management screen, go to **AD FS ➜ Application Groups** and click **Add Application Group...** .. image:: _static/2016/01_add_app_group.png :scale: 50 % ------------ Fill in a **name** for the application group, select **Web browser accessing a web application** and click **Next**. .. image:: _static/2016/02_add_app_group_wizard_page1.png :scale: 50 % ------------ Make note of the **Client Identifier** value. This will be the value for the :ref:`client_id_setting` setting. The **Redirect URI** value must match with the domain where your Django application is located and the patch where you mapped the ``django_auth_adfs`` urls in your ``urls.py`` file. If you follow the installation steps from this documentation, this should be something like ``https://your.domain.com/oauth2/callback``. .. image:: _static/2016/03_add_native_app.png :scale: 50 % ------------ Select **Permit everyone** and click **Next**. .. image:: _static/2016/04_native_app_access_policy.png :scale: 50 % ------------ Review the settings and click **Next** * The **Client ID** is the value for the :ref:`client_id_setting` setting. * The **Relying Party ID** is the value for the :ref:`relying_party_id_setting` and :ref:`audience_setting` setting. While they both are the same in this screenshot, they can be changed independently from one another afterwards. .. image:: _static/2016/05_review_settings.png :scale: 50 % ------------ Close the wizard by clicking **Close**. Our django application is now registered in ADFS. .. image:: _static/2016/06_wizard_end.png :scale: 50 % Step 2 - Configuring Claims --------------------------- Open the **properties** for the application group we just created. Select the **Web application** entry and click **Edit** .. image:: _static/2016/07_app_group_settings.png :scale: 50 % ------------ On the **Issuance Transform Rules** tab, click the **Add Rule** button .. image:: _static/2016/08_add_claim_rules.png :scale: 50 % ------------ Select **Send LDAP Attributes as Claims** and click **Next** .. image:: _static/2016/08_add_ldap_attributes_part1.png :scale: 50 % ------------ Give the rule a name and select **Active Directory** as the attribute store. Then configure the below claims. +----------------------------------+----------------------+ | LDAP Attribute | Outgoing Claim Type | +==================================+======================+ | E-Mail-Addresses | E-Mail Address | +----------------------------------+----------------------+ | Given-Name | Given Name | +----------------------------------+----------------------+ | Surname | Surname | +----------------------------------+----------------------+ | Token-Groups - Unqualified Names | Group | +----------------------------------+----------------------+ | SAM-Account-Name | Windows Account Name | +----------------------------------+----------------------+ .. image:: _static/2016/08_add_ldap_attributes_part2.png :scale: 50 % Click **Finish** to save the settings .. note:: The **Outgoing Claim Type** is what will be visible in the JWT Access Token. The first 3 claims will go into the :ref:`claim_mapping_setting` setting. The 4th is the :ref:`groups_claim_setting` setting. The 5th is the :ref:`username_claim_setting` setting. You cannot just copy the outgoing claim type value from this screen and use it in the settings. The name of the claim as it is in the JWT token is the short name which you can find in the AD FS Management screen underneath **AD FS ➜ Service ➜ Claim Descriptions** ------------ You should now see the rule added. Click **OK** a couple of times to save the settings. Step 3 - Determine configuration settings ----------------------------------------- Once everything is configured, you can use the below PowerShell commands to determine the value for the settings of this package. The ``<<<<<<`` in the output indicate which settings should match this value. .. code-block:: ps1con PS C:\Users\Administrator> Get-AdfsNativeClientApplication Name : Django Application - Native application Identifier : 487d8ff7-80a8-4f62-b926-c2852ab06e94 <<< CLIENT_ID <<< ApplicationGroupIdentifier : Django Application Description : Enabled : True RedirectUri : {http://web.example.com:8000/oauth2/callback} LogoutUri : PS C:\Users\Administrator> Get-AdfsProperties | select HostName | Format-List HostName : adfs.example.com <<< SERVER <<< PS C:\Users\Administrator> Get-AdfsWebApiApplication | select Identifier | Format-List Identifier : {web.example.com} <<< RELYING_PARTY_ID and AUDIENCE <<< If you followed this guide, you should end up with a configuration like this. .. code-block:: python AUTH_ADFS = { "SERVER": "adfs.example.com", "CLIENT_ID": "487d8ff7-80a8-4f62-b926-c2852ab06e94", "RELYING_PARTY_ID": "web.example.com", "AUDIENCE": "microsoft:identityserver:web.example.com", "CLAIM_MAPPING": {"first_name": "given_name", "last_name": "family_name", "email": "email"}, "USERNAME_CLAIM": "winaccountname", "GROUP_CLAIM": "group" } Enabling SSO for other browsers ------------------------------- By default, ADFS only supports seamless single sign-on for Internet Explorer. In other browsers, users will always be prompted for their username and password. To enable SSO also for other browsers like Chrome and Firefox, execute the following PowerShell command: .. code-block:: powershell [System.Collections.ArrayList]$UserAgents = Get-AdfsProperties | select -ExpandProperty WIASupportedUserAgents $UserAgents.Add("Mozilla/5.0") Set-ADFSProperties -WIASupportedUserAgents $UserAgents After that, restart the ADFS service on every server in the ADFS farm. For firefox, you'll also have to change it's ``network.automatic-ntlm-auth.trusted-uris`` setting to include the URI of your ADFS server. ================================================ FILE: docs/azure_ad_config_guide.rst ================================================ Azure AD ======== Getting this module to work is sometimes not so straightforward. If you're not familiar with JWT tokens or Azure AD itself, it might take some tries to get all the settings right. This guide tries to give a basic overview of how to configure Azure AD and how to determine the settings for django-auth-adfs. Installing and configuring the basics of Azure AD is not explained here. Step 1 - Register a backend application --------------------------------------- After signing in to `Azure `_. Open the **Azure Active Directory** dashboard. .. image:: _static/AzureAD/01-azure_active_directory.png :scale: 50 % ------------ Note down your **Tenant_ID** as you will need it later. .. image:: _static/AzureAD/02-azure_dashboard.png :scale: 50 % ------------ Navigate to **App Registrations**, then click **New registration** in the upper left hand corner. .. image:: _static/AzureAD/03-new_registrations.png :scale: 50 % ------------ Here you register your application. 1. The display name of your application. 2. What type of accounts can access your application. 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*). .. image:: _static/AzureAD/04-app_registrations_specs.png :scale: 50 % ------------ 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. .. image:: _static/AzureAD/05-application_overview.png :scale: 50 % ------------ Next we need to generate a **Client_Secret**. Your application will use this to prove its identity when requesting a token. .. image:: _static/AzureAD/06-add_Secret.png :scale: 50 % ------------ Give it a short (display) name. This is only used by you, to help keep track of in case you make more client secrets. .. image:: _static/AzureAD/07-add_Secret_name.png :scale: 50 % ------------ Copy your secret (value). It will be become hidden after a short time, so be sure to note this quickly. .. image:: _static/AzureAD/08-copy_Secret.png :scale: 50 % ------------ Step 2 - Configuring settings.py -------------------------------- We need to update the ``settings.py`` to accommodate our registered Azure AD application. Replace your AUTH_ADFS with this. .. code-block:: python # Client secret is not public information. Should store it as an environment variable. client_id = 'Your client id here' client_secret = 'Your client secret here' tenant_id = 'Your tenant id here' AUTH_ADFS = { 'AUDIENCE': [f'api://{client_id}', client_id], 'CLIENT_ID': client_id, 'CLIENT_SECRET': client_secret, 'CLAIM_MAPPING': { 'first_name': 'given_name', 'last_name': 'family_name', 'email': 'email' }, 'GROUPS_CLAIM': 'roles', 'MIRROR_GROUPS': True, 'USERNAME_CLAIM': 'upn', 'TENANT_ID': tenant_id, 'RELYING_PARTY_ID': client_id } Add this to your INSTALLED_APPS. .. code-block:: python INSTALLED_APPS = [ ... 'django_auth_adfs', ... ] Add this to your AUTHENTICATION_BACKENDS. .. code-block:: python AUTHENTICATION_BACKENDS = [ ... 'django_auth_adfs.backend.AdfsAccessTokenBackend', 'django_auth_adfs.backend.AdfsAuthCodeBackend' ... ] Add this path to your project's ``urls.py`` file. .. code-block:: python urlpatterns = [ ... path('oauth2/', include('django_auth_adfs.urls')), ... ] Step 3 - Register and configure an Azure AD frontend application ---------------------------------------------------------------- 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). .. image:: _static/AzureAD/09_register_frontend_app.PNG :scale: 50 % ------------ Copy your frontend's client ID, you will need later .. image:: _static/AzureAD/10_copy-frontend-client_id.png :scale: 50 % ------------ Now we need to add a scope of permissions to our API. Navigate back to app registrations and click on your backend application. Go to **Expose an API** in the sidebar and press **add a scope**. .. image:: _static/AzureAD/11-navigate_to_expose_an_api.PNG :scale: 50 % ------------ If you have not created an Application ID URI, it will be autogenerated for you. Select it and press **save and continue**. .. image:: _static/AzureAD/13_set_app_id.PNG :scale: 50 % ------------ 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). .. image:: _static/AzureAD/14_add_a_scope.PNG :scale: 50 % ------------ Now we are going to add our frontend application as a trusted app for our backend. Press **add a client application** .. image:: _static/AzureAD/15_add_authorized_app_1.png :scale: 50 % ------------ Here you need to paste in your frontend application (client) id. .. image:: _static/AzureAD/16_add_authorized_app_2.PNG :scale: 50 % ------------ Now navigate back to app registrations. Click on your **frontend** application and navigate to API permissions. Press **add a permission**. .. image:: _static/AzureAD/17_navigate_to_api_permissions.PNG :scale: 50 % ------------ 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.) .. image:: _static/AzureAD/18_add_permission.PNG :scale: 50 % ------------ 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** .. image:: _static/AzureAD/19_add-permission-2.PNG :scale: 50 % ------------ 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. .. image:: _static/AzureAD/20_add-permission-3.png :scale: 50 % ================================================ FILE: docs/conf.py ================================================ #!/usr/bin/env python3 # -*- coding: utf-8 -*- # # django_auth_adfs documentation build configuration file, created by # sphinx-quickstart on Fri Jan 29 11:23:45 2016. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import sys import os import sphinx_rtd_theme # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.abspath('../')) from django_auth_adfs import __version__ # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. #needs_sphinx = '1.0' # Prevent non local immage warnings from showing suppress_warnings = ['image.nonlocal_uri'] # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.napoleon' ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # source_suffix = ['.rst', '.md'] source_suffix = '.rst' # The encoding of source files. #source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = 'django_auth_adfs' import datetime copyright = str(datetime.date.today().year) + ', Joris Beckers' author = 'Joris Beckers' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = __version__ # The full version, including alpha/beta/rc tags. release = __version__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: #today = '' # Else, today_fmt is used as the format for a strftime call. #today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ['_build'] # The reST default role (used for this markup: `text`) to use for all # documents. #default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. #add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). #add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. #show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. #keep_warnings = False # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'sphinx_rtd_theme' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. #html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". #html_title = None # A shorter title for the navigation bar. Default is the same as html_title. #html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. #html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. #html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. #html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. #html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. #html_use_smartypants = True # Custom sidebar templates, maps document names to template names. #html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. #html_additional_pages = {} # If false, no module index is generated. #html_domain_indices = True # If false, no index is generated. #html_use_index = True # If true, the index is split into individual pages for each letter. #html_split_index = False # If true, links to the reST sources are added to the pages. #html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. #html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. #html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. #html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). #html_file_suffix = None # Language to be used for generating the HTML full-text search index. # Sphinx supports the following languages: # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr' #html_search_language = 'en' # A dictionary with options for the search language support, empty by default. # Now only 'ja' uses this config value #html_search_options = {'type': 'default'} # The name of a javascript file (relative to the configuration directory) that # implements a search results scorer. If empty, the default will be used. #html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. htmlhelp_basename = 'django_auth_adfsdoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). #'pointsize': '10pt', # Additional stuff for the LaTeX preamble. #'preamble': '', # Latex figure (float) alignment #'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ (master_doc, 'django_auth_adfs.tex', 'django_auth_adfs Documentation', 'Joris Beckers', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. #latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. #latex_use_parts = False # If true, show page references after internal links. #latex_show_pagerefs = False # If true, show URL addresses after external links. #latex_show_urls = False # Documents to append as an appendix to all manuals. #latex_appendices = [] # If false, no module index is generated. #latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ (master_doc, 'django_auth_adfs', 'django_auth_adfs Documentation', [author], 1) ] # If true, show URL addresses after external links. #man_show_urls = False # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ (master_doc, 'django_auth_adfs', 'django_auth_adfs Documentation', author, 'django_auth_adfs', 'One line description of project.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. #texinfo_appendices = [] # If false, no module index is generated. #texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. #texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. #texinfo_no_detailmenu = False ================================================ FILE: docs/config_guides.rst ================================================ ADFS Config Guides ================== .. toctree:: :maxdepth: 3 adfs_3.0_config_guide adfs_4.0_config_guide azure_ad_config_guide ================================================ FILE: docs/contributing.rst ================================================ .. include:: ../CONTRIBUTING.rst ================================================ FILE: docs/demo.rst ================================================ Demo ==== A ``Vagrantfile`` and example project are available to show what's needed to convert a Django project from form based authentication to ADFS authentication. Prerequisites ------------- * A hypervisor like `virtualbox `__. * A working `vagrant `__ installation. On Debian 11 (bullseye) if you use the `stock vagrant package `__ you need to install these plugins:: vagrant plugin install winrm vagrant plugin install winrm-fs vagrant plugin install winrm-elevated vagrant plugin install vagrant-reload * The github repository should be cloned/downloaded in some directory. This guide assumes you're using VirtualBox, but another hypervisor should also work. If you choose to use another one, make sure there's a windows server 2019 vagrant box available for it. Components ---------- The demo consists of 2 parts: * A web server VM. * A windows server 2019 VM. The webserver will run Django and is reachable at ``http://web.example.com:8000``. The windows server will run a domain controller and ADFS service. Starting the environment ------------------------ Web server ~~~~~~~~~~ First we get the web server up and running. #. Navigate to the directory where you cloned/downloaded the github repository. #. Bring up the web server by running the command:: vagrant up web #. Wait as the vagrant box is downloaded and the needed software installed. #. Next, SSH into the web server:: vagrant ssh web #. Once connected, start the Django project:: cd /vagrant/demo/adfs python3 manage.py runserver 0.0.0.0:8000 you should now be able to browse the demo project by opening the page `http://localhost:8000 `__ in a browser. Pages requiring authentication wont work, because the ADFS server is not there yet. .. note:: There are 2 versions of the web example. One is a forms based authentication example, the other depends on ADFS. If you want to run the forms based example, change the path above to ``/vagrant/demo/formsbased`` ADFS server ~~~~~~~~~~~ The next vagrant box to start is the ADFS server. The scripts used for provisioning the ADFS server can be found in the folder ``/vagrant`` inside the repository. #. Navigate to the directory where you cloned/downloaded the github repository. #. Bring up the ADFS server by running the command:: vagrant up adfs #. Wait as the vagrant box is downloaded and the needed software installed. **For this windows box, it takes a couple of coffees before it's done.** #. Next, open window showing the login screen of the windows server. The login credentials are:: username: vagrant password: vagrant #. Once logged in, install a browser like Chrome of Firefox. #. Next, in that browser on the windows server, verify you can open the page `http://web.example.com:8000 `__ In the AD FS management console, you can check how the example project is configured. The config is in the **Application Groups** folder. .. note:: You wont be able to test the demo project from outside the windows machine because port 443 is not forwarded and name resolution of adfs.example.com won't work. You can workaround this by forwarding that port 443 from the guest to port 443 on your host and manually adding the right IP addresses in you hosts file. .. note:: Because windows server virtual boxes are rather rare on the vagrant cloud (they need to be rebuild every 180 days), it might be that the box specified in the ``Vagrantfile`` doesn't work anymore. If you replace it by another one that's just a vanilla windows server, it should work. Using the demo -------------- Once everything is up and running, you can click around in the very basic poll app that the demo is. * The bottom of the page shows details about the logged in user. * There are 2 users already created in the Active Directory domain. Both having the default password ``Password123`` * ``bob@example.com`` which is a Django super user because he's a member of active directory group ``django_admins``. * ``alice@example.com`` which is a regular Django user. * By default, only the page to vote on a poll requires you to be logged in. * There are no questions by default. Create some in the admin section with user ``bob``. * Compare the files in ``/vagrant/demo/formsbased`` to those in ``/vagrant/demo/adfs`` to see what was changed to enable ADFS authentication in a demo project. ================================================ FILE: docs/faq.rst ================================================ Frequently Asked Questions ========================== Why am I always redirected to ``/accounts/profile/`` after login? ----------------------------------------------------------------- This is default Django behaviour. You can change it by setting the Django setting named `LOGIN_REDIRECT_URL `_. How do I store additional info about a user? -------------------------------------------- ``django_auth_adfs`` can only store information in existing fields of the user model. If you want to store extra info, you'll have to extend the default user model with extra fields and adjust the :ref:`claim_mapping_setting` setting accordingly. `You can read about how to extend the user model here `_ I'm receiving an ``SSLError: CERTIFICATE_VERIFY_FAILED`` error. --------------------------------------------------------------- double check your ``CA_BUNDLE`` setting. Most likely your ADFS server is using a certificate signed by an enterprise root CA. you'll need to put it's certificate in a file and set ``CA_BUNDLE`` to it's path. I'm receiving an ``KeyError: 'upn'`` error when authenticating against Azure AD. -------------------------------------------------------------------------------- In some circumstances, Azure AD does not send the ``upn`` claim used to determine the username. It's observed to happen with guest users who's **source** in the users overview of Azure AD is ``Microsoft Account`` instead of ``Azure Active Directory``. In such cases, try setting the :ref:`username_claim_setting` to ``email`` instead of the default ``upn``. Or create a new user in your Azure AD directory. Why am I prompted for a username and password in Chrome/Firefox? ---------------------------------------------------------------- By default, ADFS only triggers seamless single sign-on for Internet Explorer or Edge. Have a look at the ADFS configuration guides for details about how to got this working for other browsers also. Why is a user added and removed from the same group on every login? ------------------------------------------------------------------- This can be caused by having a case insensitive database, such as a ``MySQL`` database with default settings. You can read more about `collation settings `_ in the official documentation. The redirect_uri starts with HTTP, while my site is HTTPS only. --------------------------------------------------------------- When you run Django behind a TLS terminating webserver or load balancer, then Django doesn't know the client arrived over a HTTPS connection. It will only see the plain HTTP traffic. Therefor, the link it generates and sends to ADFS as the ``redirect_uri`` query parameter, will start with HTTP, instead of HTTPS. To tell Django to generate HTTPS links, you need to set it's ``SECURE_PROXY_SSL_HEADER`` setting and inject the correct HTTP header and value on your web server. For more info, have a look at `Django's docs `_. I cannot get it working! ------------------------ Make sure you follow the instructions in the troubleshooting guide. It will enable debugging and can quickly tell you what is wrong. Also, walk through the :ref:`settings` once, you might find one that needs to be adjusted to match your situation. ================================================ FILE: docs/index.rst ================================================ ADFS Authentication for Django ============================== .. image:: https://readthedocs.org/projects/django-auth-adfs/badge/?version=latest :target: http://django-auth-adfs.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status .. image:: https://img.shields.io/pypi/v/django-auth-adfs.svg :target: https://pypi.python.org/pypi/django-auth-adfs .. image:: https://img.shields.io/pypi/pyversions/django-auth-adfs.svg :target: https://pypi.python.org/pypi/django-auth-adfs#downloads .. image:: https://img.shields.io/pypi/djversions/django-auth-adfs.svg :target: https://pypi.python.org/pypi/django-auth-adfs .. image:: https://codecov.io/github/snok/django-auth-adfs/coverage.svg?branch=main :target: https://codecov.io/github/snok/django-auth-adfs?branch=main A Django authentication backend for Microsoft ADFS and Azure AD * Free software: BSD License * Homepage: https://github.com/snok/django-auth-adfs * Documentation: http://django-auth-adfs.readthedocs.io/ Features -------- * Integrates Django with Active Directory on Windows 2012 R2, 2016 or Azure AD in the cloud. * Provides seamless single sign on (SSO) for your Django project on intranet environments. * Auto creates users and adds them to Django groups based on info received from ADFS. * Django Rest Framework (DRF) integration: Authenticate against your API with an ADFS access token. Contents -------- .. toctree:: :maxdepth: 3 install oauth2_explained settings_ref config_guides middleware signals rest_framework demo troubleshooting faq contributing ================================================ FILE: docs/install.rst ================================================ .. _install: Installation ============ Requirements ------------ * Python 3.9 and above * Django 4.2 and above You will also need the following: * A properly configured Microsoft Windows server 2012 R2 or 2016 with the **AD FS** role installed or an Azure Active Directory setup. * A root CA bundle containing the root CA that signed the webserver certificate of your ADFS server if signed by an enterprise CA. .. note:: When using Azure AD, beware of the following limitations: * Users have no email address unless you assigned an Office 365 license to that user. * Groups are listed with their GUID in the groups claim. Meaning you have to create your groups in Django using these GUIDs, instead of their name. * Usernames are in the form of an email address, hence users created in Django follow this format. * You cannot send any custom claims, only those predefined by Azure AD. Package installation -------------------- Python package:: pip install django-auth-adfs Setting up django ----------------- In your project's ``settings.py`` add these settings. .. code-block:: python AUTHENTICATION_BACKENDS = ( ... 'django_auth_adfs.backend.AdfsAuthCodeBackend', ... ) INSTALLED_APPS = ( ... # Needed for the ADFS redirect URI to function 'django_auth_adfs', ... # checkout the documentation for more settings AUTH_ADFS = { "SERVER": "adfs.yourcompany.com", "CLIENT_ID": "your-configured-client-id", "RELYING_PARTY_ID": "your-adfs-RPT-name", # Make sure to read the documentation about the AUDIENCE setting # when you configured the identifier as a URL! "AUDIENCE": "microsoft:identityserver:your-RelyingPartyTrust-identifier", "CA_BUNDLE": "/path/to/ca-bundle.pem", "CLAIM_MAPPING": {"first_name": "given_name", "last_name": "family_name", "email": "email"}, } # Configure django to redirect users to the right URL for login LOGIN_URL = "django_auth_adfs:login" LOGIN_REDIRECT_URL = "/" ######################## # OPTIONAL SETTINGS ######################## MIDDLEWARE = ( ... # With this you can force a user to login without using # the LoginRequiredMixin on every view class # # You can specify URLs for which login is not enforced by # specifying them in the LOGIN_EXEMPT_URLS setting. 'django_auth_adfs.middleware.LoginRequiredMiddleware', ) # You can point login failures to a custom Django function based view for customization of the UI CUSTOM_FAILED_RESPONSE_VIEW = 'dot.path.to.custom.views.login_failed' In your project's ``urls.py`` add these paths: .. code-block:: python urlpatterns = [ ... path('oauth2/', include('django_auth_adfs.urls')), ] This will add these paths to Django: * ``/oauth2/login`` where users are redirected to, to initiate the login with ADFS. * ``/oauth2/login_no_sso`` where users are redirected to, to initiate the login with ADFS but forcing a login screen. * ``/oauth2/callback`` where ADFS redirects back to after login. So make sure you set the redirect URI on ADFS to this. * ``/oauth2/logout`` which logs out the user from both Django and ADFS. Below is sample Django template code to use these paths depending if you'd like to use GET or POST requests. Logging out was deprecated in `Django 4.1 `_. - Using GET requests: .. code-block:: html Logout Login Login (no SSO) - Using POST requests: .. code-block:: html+django
{% csrf_token %}
{% csrf_token %}
{% csrf_token %}
================================================ FILE: docs/make.bat ================================================ @ECHO OFF REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set BUILDDIR=_build set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . set I18NSPHINXOPTS=%SPHINXOPTS% . if NOT "%PAPER%" == "" ( set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% ) if "%1" == "" goto help if "%1" == "help" ( :help echo.Please use `make ^` where ^ is one of echo. html to make standalone HTML files echo. dirhtml to make HTML files named index.html in directories echo. singlehtml to make a single large HTML file echo. pickle to make pickle files echo. json to make JSON files echo. htmlhelp to make HTML files and a HTML help project echo. qthelp to make HTML files and a qthelp project echo. devhelp to make HTML files and a Devhelp project echo. epub to make an epub echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter echo. text to make text files echo. man to make manual pages echo. texinfo to make Texinfo files echo. gettext to make PO message catalogs echo. changes to make an overview over all changed/added/deprecated items echo. xml to make Docutils-native XML files echo. pseudoxml to make pseudoxml-XML files for display purposes echo. linkcheck to check all external links for integrity echo. doctest to run all doctests embedded in the documentation if enabled echo. coverage to run coverage check of the documentation if enabled goto end ) if "%1" == "clean" ( for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i del /q /s %BUILDDIR%\* goto end ) REM Check if sphinx-build is available and fallback to Python version if any %SPHINXBUILD% 1>NUL 2>NUL if errorlevel 9009 goto sphinx_python goto sphinx_ok :sphinx_python set SPHINXBUILD=python -m sphinx.__init__ %SPHINXBUILD% 2> nul if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) :sphinx_ok if "%1" == "html" ( %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/html. goto end ) if "%1" == "dirhtml" ( %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. goto end ) if "%1" == "singlehtml" ( %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. goto end ) if "%1" == "pickle" ( %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the pickle files. goto end ) if "%1" == "json" ( %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the JSON files. goto end ) if "%1" == "htmlhelp" ( %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run HTML Help Workshop with the ^ .hhp project file in %BUILDDIR%/htmlhelp. goto end ) if "%1" == "qthelp" ( %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run "qcollectiongenerator" with the ^ .qhcp project file in %BUILDDIR%/qthelp, like this: echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-auth-adfs.qhcp echo.To view the help file: echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-auth-adfs.ghc goto end ) if "%1" == "devhelp" ( %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp if errorlevel 1 exit /b 1 echo. echo.Build finished. goto end ) if "%1" == "epub" ( %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub if errorlevel 1 exit /b 1 echo. echo.Build finished. The epub file is in %BUILDDIR%/epub. goto end ) if "%1" == "latex" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex if errorlevel 1 exit /b 1 echo. echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. goto end ) if "%1" == "latexpdf" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex cd %BUILDDIR%/latex make all-pdf cd %~dp0 echo. echo.Build finished; the PDF files are in %BUILDDIR%/latex. goto end ) if "%1" == "latexpdfja" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex cd %BUILDDIR%/latex make all-pdf-ja cd %~dp0 echo. echo.Build finished; the PDF files are in %BUILDDIR%/latex. goto end ) if "%1" == "text" ( %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text if errorlevel 1 exit /b 1 echo. echo.Build finished. The text files are in %BUILDDIR%/text. goto end ) if "%1" == "man" ( %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man if errorlevel 1 exit /b 1 echo. echo.Build finished. The manual pages are in %BUILDDIR%/man. goto end ) if "%1" == "texinfo" ( %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo if errorlevel 1 exit /b 1 echo. echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. goto end ) if "%1" == "gettext" ( %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale if errorlevel 1 exit /b 1 echo. echo.Build finished. The message catalogs are in %BUILDDIR%/locale. goto end ) if "%1" == "changes" ( %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes if errorlevel 1 exit /b 1 echo. echo.The overview file is in %BUILDDIR%/changes. goto end ) if "%1" == "linkcheck" ( %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck if errorlevel 1 exit /b 1 echo. echo.Link check complete; look for any errors in the above output ^ or in %BUILDDIR%/linkcheck/output.txt. goto end ) if "%1" == "doctest" ( %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest if errorlevel 1 exit /b 1 echo. echo.Testing of doctests in the sources finished, look at the ^ results in %BUILDDIR%/doctest/output.txt. goto end ) if "%1" == "coverage" ( %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage if errorlevel 1 exit /b 1 echo. echo.Testing of coverage in the sources finished, look at the ^ results in %BUILDDIR%/coverage/python.txt. goto end ) if "%1" == "xml" ( %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml if errorlevel 1 exit /b 1 echo. echo.Build finished. The XML files are in %BUILDDIR%/xml. goto end ) if "%1" == "pseudoxml" ( %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml if errorlevel 1 exit /b 1 echo. echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. goto end ) :end ================================================ FILE: docs/middleware.rst ================================================ Login Middleware ================ **django-auth-adfs** ships with a middleware class named ``LoginRequiredMiddleware``. You can use it to force an unauthenticated user to login and be redirected to the URL specified in in Django's ``LOGIN_URL`` setting without having to add code to every view. By default it's disabled for the page defined in the ``LOGIN_URL`` setting and the redirect page for ADFS. But by setting the ``LOGIN_EXEMPT_URLS`` setting, you can exclude other pages from authentication. Have a look at the :ref:`settings` for more information. To enable the middleware, add it to ``MIDDLEWARE`` in ``settings.py`` (or ``MIDDLEWARE_CLASSES`` if using Django <1.10. make sure to add it after any other session or authentication middleware to be sure all other methods of identifying the user are tried first. In your ``settings.py`` file, add the following: .. code-block:: python MIDDLEWARE = ( ... 'django_auth_adfs.middleware.LoginRequiredMiddleware', ) AUTH_ADFS = { ... "LOGIN_EXEMPT_URLS": ["api/", "public/"], ... } ================================================ FILE: docs/oauth2_explained.rst ================================================ OAuth2 and ADFS explained ========================= This chapter tries to explain how ADFS implements the OAuth2 and OpenID Connect standard and how we can use this in Django. OAuth2 vs. OpenID Connect ------------------------- What's `OAuth2 `__? The OAuth 2.0 authorization framework enables a third-party application to obtain limited access to an HTTP service, either on behalf of a resource owner by orchestrating an approval interaction between the resource owner and the HTTP service, or by allowing the third-party application to obtain access on its own behalf. What's important is that it's only an **authorization** framework. It only tells you what the user is allowed to do but it doesn't tell you who the user is. At its core, there's nothing in the protocol that gives you info about the user. To solve this, there's the `OpenID Connect `__ framework. OpenID Connect 1.0 is a simple identity layer on top of the OAuth 2.0 [RFC6749] protocol. It enables Clients to verify the identity of the End-User based on the authentication performed by an Authorization Server, as well as to obtain basic profile information about the End-User in an interoperable and REST-like manner. So, where the OAuth2 protocol lacks any user identifiable info, OpenID Connect does give you info about who the user is. The access token returned by OpenID Connect is a signed JWT token (JSON Web Token) containing claims about the user. ``django-auth-adfs`` uses this access token to validate the issuer of the token by verifying the signature and also uses it to keep the Django users database up to date and at the same time authenticate users. Depending on the version of ADFS, there's support for different pieces of these protocol. The table below tries to list the support in various ADFS versions: ================================== ============ ========= ======== Protocol ADFS 2012 R2 ADFS 2016 Azure AD ================================== ============ ========= ======== OAuth2 Yes Yes Yes OpenID Connect No** Yes Yes ================================== ============ ========= ======== ** ADFS 2012 doesn't implement OpenID Connect, but it does return the access token as a JWT token, just like OpenID Connect would. OpenID Connect / OAuth2 Flow support: ================================== ============ ========= ======== Version ADFS 2012 R2 ADFS 2016 Azure AD ================================== ============ ========= ======== **Authorization code grant** Yes Yes Yes Implicit grant no Yes Yes Resource owner password credential no Yes Yes Client credential grant no Yes Yes ================================== ============ ========= ======== References: * https://blogs.msdn.microsoft.com/nicold/2018/03/23/oauth-2-0-protocol-support-level-for-adfs-2012r2-vs-adfs-2016/ The **Authorization Code Grant** is what ``django-auth-adfs`` uses. OAuth2 and Django ----------------- Let's step through the process of how ``django-auth-adfs`` uses OAuth2 to authenticate and authorize users. .. note:: In all the graphs below, remember that the access token is what contains the info about our user in the form of a signed JWT token. The OAuth2 `RFC 6749 `__ specifies the `Authorization Code Grant `__ flow as follows: .. code-block:: text +----------+ | Resource | | Owner | | | +----------+ ^ | (B) +----|-----+ Client Identifier +---------------+ | -+----(A)-- & Redirection URI ---->| | | User- | | Authorization | | Agent -+----(B)-- User authenticates --->| Server | | | | | | -+----(C)-- Authorization Code ---<| | +-|----|---+ +---------------+ | | ^ v (A) (C) | | | | | | ^ v | | +---------+ | | | |>---(D)-- Authorization Code ---------' | | Client | & Redirection URI | | | | | |<---(E)----- Access Token -------------------' +---------+ (w/ Optional Refresh Token) Note: The lines illustrating steps (A), (B), and (C) are broken into two parts as they pass through the user-agent. The flow illustrated includes the following steps: (A) The client initiates the flow by directing the resource owner's user-agent to the authorization endpoint. The client includes its client identifier, requested scope, local state, and a redirection URI to which the authorization server will send the user-agent back once access is granted (or denied). (B) The authorization server authenticates the resource owner (via the user-agent) and establishes whether the resource owner grants or denies the client's access request. (C) Assuming the resource owner grants access, the authorization server redirects the user-agent back to the client using the redirection URI provided earlier (in the request or during client registration). The redirection URI includes an authorization code and any local state provided by the client earlier. (D) The client requests an access token from the authorization server's token endpoint by including the authorization code received in the previous step. When making the request, the client authenticates with the authorization server. The client includes the redirection URI used to obtain the authorization code for verification. (E) The authorization server authenticates the client, validates the authorization code, and ensures that the redirection URI received matches the URI used to redirect the client in step (C). If valid, the authorization server responds back with an access token and, optionally, a refresh token. One thing missing in the graph from the RFC is the ``Resource Server``. Let's add it to make things complete: .. code-block:: text +----------+ | Resource | | Owner | | | +----------+ ^ | (B) +----|-----+ Client Identifier +---------------+ | -+----(A)-- & Redirection URI ---->| | | User- | | Authorization | | Agent -+----(B)-- User authenticates --->| Server | | | | | | -+----(C)-- Authorization Code ---<| | +-|----|---+ +---------------+ | | ^ v (A) (C) | | | | | | ^ v | | +---------+ | | | |>---(D)-- Authorization Code ---------' | | Client | & Redirection URI | | | | | |<---(E)----- Access Token -------------------' +---------+ (w/ Optional Refresh Token) | ^ | | (F) Access Token | (G) v | +-----------------+ | | | Resource Server | | | +-----------------+ Extra steps (F) The client makes a protected resource request to the resource server by presenting the access token. (G) The resource server validates the access token, and if valid, serves the request. Alright, now that we have the entire flow, let's translate the roles to our components and use a bit more comprehensible terms: .. code-block:: text +----------+ | | | User | | | +----------+ ^ | (B) Resource +----|-----+ & Client Identifier +---------------+ | -+----(A)-- & Redirection URI ---->| | | Web | | ADFS | | Browser -+----(B)-- User authenticates --->| Server | | | | | | -+----(C)-- Authorization Code ---<| | +-|---|----+ +---------------+ | | ^ ^ v (A) (C)(G) | | | | | | | ^ v | | | +--------|+ | | | |>---(D)-- Authorization Code ---------' | | Django | & Redirection URI | | Login | | | |<---(E)----- Access Token -------------------' +---------+ (w/ Optional Refresh Token) | ^ | | (F) Access Token | (G) Session ID v | +-------------------------------+ | | | Django Authentication Backend | | | +-------------------------------+ The following things changed: * A ``resource`` parameter was added to step **A**. This is an ADFS specific thing used to identify which application. * Step **G** was extended up to the web browser. Resembling the session cookie sent back to the web browser. * ``Resource Owner`` ➜ ``User`` * ``User-Agent`` ➜ ``Web Browser`` * ``Authorization Serve`` ➜ ``ADFS Server`` * ``Client`` ➜ ``Django Login`` * ``Resource Server`` ➜ ``Django Authentication Backend`` Notice how 2 roles were replaced by "pieces" of Django. Django effectively takes up 2 roles here. If you were to split Django in 2 parts, it's login pages and the authentication backends, then the **login pages** would map to the ``Client`` role. It wants to get a session for the user and give it a session cookie. The **authentication backend** maps to the ``Resource Server`` role, authenticating/authorizing the user and creating the session. The session you can think of as being the protected resource. Once the session is created, OAuth2 isn't used anymore. Django uses its sessions to authenticate and authorize the user on subsequent requests. On the ADFS side, you need to configure both the ``Client`` role part of Django (called a Native Application in ADFS 4.0), as well as the ``Resource Server`` part (called a Web Application in ADFS 4.0). Rest Framework and OAuth2 ------------------------- When activating Django Rest Framework integration to protect an API, the roles shift once more. The example assumes a situation where you use a script or some other application to make requests to your API. In that case, the OAuth2 flow also changes from the ``Authorization Code Grant`` flow to the ``Resource Owner Password Credentials Grant`` flow. .. note:: If you would call the API from a Single Page Application (SPA), you'll most likely be using the ``Implicit Grant`` flow. We won't explain this flow here, but the principle is sort of the same. Here's the RFC explanation again: .. code-block:: text +----------+ | Resource | | Owner | | | +----------+ v | Resource Owner (A) Password Credentials | v +---------+ +---------------+ | |>--(B)---- Resource Owner ------->| | | | Password Credentials | Authorization | | Client | | Server | | |<--(C)---- Access Token ---------<| | | | (w/ Optional Refresh Token) | | +---------+ +---------------+ The flow illustrated includes the following steps: (A) The resource owner provides the client with its username and password. (B) The client requests an access token from the authorization server's token endpoint by including the credentials received from the resource owner. When making the request, the client authenticates with the authorization server. (C) The authorization server authenticates the client and validates the resource owner credentials, and if valid, issues an access token. Again, let's add the ``Resource Server`` role to the picture: .. code-block:: text +----------+ | Resource | | Owner | | | +----------+ v | Resource Owner (A) Password Credentials | v +---------+ +---------------+ | |>--(B)---- Resource Owner ------->| | | | Password Credentials | Authorization | | Client | | Server | | |<--(C)---- Access Token ---------<| | | | (w/ Optional Refresh Token) | | +---------+ +---------------+ | ^ | | (D) Access Token | (E) v | +-----------------+ | | | Resource Server | | | +-----------------+ Extra steps (D) The client makes a protected resource request to the resource server by presenting the access token. (E) The resource server validates the access token, and if valid, serves the request. And let's map it to our components: .. code-block:: text +----------+ | | | User | | | +----------+ v | Resource Owner (A) Password Credentials | v +-------------+ +---------------+ | |>--(B)---- Resource Owner ------->| | | | Password Credentials | ADFS | | Application | | Server | | |<--(C)---- Access Token ---------<| | | | (w/ Optional Refresh Token) | | +-------------+ +---------------+ | ^ | | (D) Access Token | (E) v | +-----------------------+ | | | Django Rest Framework | | API | | | +-----------------------+ Let's go over the changes again: * ``Resource Owner`` ➜ ``User`` * ``Client`` ➜ ``Application`` * ``Resource Server`` ➜ ``Django Rest Framework API`` In this case, a user inputs his username and password into an application/script. The application fetches an access token on behalf of the user and uses it to make calls to you API. ADFS and OAuth2 lingo compared ------------------------------ Potayto, potahto... OAuth2 and ADFS don't keep the same name for components. Below is an overview of what OAuth2 role maps to which configuration part on ADFS. +-----------------------+----------------------+----------------------+----------------------+ | OAuth2 | Azure AD | ADFS 2016 | ADFS 2012 | +=======================+======================+======================+======================+ | Resource Owner | User | User | User | +-----------------------+----------------------+----------------------+----------------------+ | Authorization Server | ADFS server | ADFS server | ADFS server | +-----------------------+----------------------+----------------------+----------------------+ | Client | Native Application | * Native Application | Client | | | | * Server Application | | +-----------------------+----------------------+----------------------+----------------------+ | Resource Server | Web app / API | Web API | Relying Party | +-----------------------+----------------------+----------------------+----------------------+ .. note:: For ADFS 2016, we assumed you use **application group** configuration instead of the "old-fashion" Relying Party Trust config. For ADFS 2012, the client part is not visible from the GUI and can only be configured via PowerShell commands. ================================================ FILE: docs/requirements.txt ================================================ sphinx_rtd_theme ================================================ FILE: docs/rest_framework.rst ================================================ Rest Framework integration ========================== Setup ----- When using Django Rest Framework, you can also use this package to authenticate your REST API clients. For this you need to do some extra configuration. You also need to install ``djangorestframework`` (or add it to your project dependencies):: pip install djangorestframework The default ``AdfsBackend`` backend expects an ``authorization_code``. The backend will take care of obtaining an ``access_code`` from the Adfs server. With the Django Rest Framework integration the client application needs to acquire the access token by itself. See for an example: :ref:`request-access-token`. To authenticate against the API you need to enable the ``AdfsAccessTokenBackend``. Steps to enable the Django Rest Framework integration are as following: Add an extra authentication class to Django Rest Framework in ``settings.py``: .. code-block:: python REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( 'django_auth_adfs.rest_framework.AdfsAccessTokenAuthentication', 'rest_framework.authentication.SessionAuthentication', ) } Enable the ``AdfsAccessTokenBackend`` authentication backend in ``settings.py``: .. code-block:: python AUTHENTICATION_BACKENDS = ( ... 'django_auth_adfs.backend.AdfsAccessTokenBackend', ... ) Prevent your API from triggering a login redirect: .. code-block:: python AUTH_ADFS = { 'LOGIN_EXEMPT_URLS': [ '^api', # Assuming you API is available at /api ], } (Optional) Override the standard Django Rest Framework login pages in your main ``urls.py``: .. code-block:: python urlpatterns = [ ... # The default rest framework urls shouldn't be included # If we include them, we'll end up with the DRF login page, # instead of being redirected to the ADFS login page. # # path('api-auth/', include('rest_framework.urls')), # # This overrides the DRF login page path('oauth2/', include('django_auth_adfs.drf_urls')), ... ] .. _request-access-token: Requesting an access token -------------------------- When everything is configured, you can request an access token in your client (script) and access the api like this: .. note:: This example is written for ADFS on windows server 2016 but with some changes in the URLs should also work for Azure AD. .. code-block:: python import getpass import requests from pprint import pprint # Ask for password user = getpass.getuser() password = getpass.getpass("Password for "+user+": ") user = user + "@example.com" # Get an access token payload = { "grant_type": "password", "resource": "your-relying-party-id", "client_id": "your-configured-client-id", "username": user, "password": password, } response = requests.post( "https://adfs.example.com/adfs/oauth2/token", data=payload, verify=False ) response.raise_for_status() response_data = response.json() access_token = response_data['access_token'] # Make a request towards this API headers = { 'Accept': 'application/json', 'Authorization': 'Bearer ' + access_token, } response = requests.get( 'https://web.example.com/api/questions', headers=headers, verify=False ) pprint(response.json()) .. note:: The following example is written for ADFS on windows server 2012 R2 and needs the ``requests-ntlm`` module. This example is here only for legacy reasons. If possible it's advised to upgrade to 2016. Support for 2012 R2 is about to end. .. code-block:: python import getpass import re import requests from requests_ntlm import HttpNtlmAuth from pprint import pprint # Ask for password user = getpass.getuser() password = getpass.getpass("Password for "+user+": ") user = "EXAMPLE\\" + user # Get a authorization code headers = {"User-Agent": "Mozilla/5.0"} params = { "response_type": "code", "resource": "your-relying-party-id", "client_id": "your-configured-client-id", "redirect_uri": "https://djangoapp.example.com/oauth2/callback" } response = requests.get( "https://adfs.example.com/adfs/oauth2/authorize/wia", auth=HttpNtlmAuth(user, password), headers=headers, allow_redirects=False, params=params, ) response.raise_for_status() code = re.search('code=(.*)', response.headers['location']).group(1) # Get an access token data = { 'grant_type': 'authorization_code', 'client_id': 'your-configured-client-id', 'redirect_uri': 'https://djangoapp.example.com/oauth2/callback', 'code': code, } response = requests.post( "https://adfs.example.com/adfs/oauth2/token", data, ) response.raise_for_status() response_data = response.json() access_token = response_data['access_token'] # Make a request towards this API headers = { 'Accept': 'application/json', 'Authorization': 'Bearer %s' % access_token, } response = requests.get( 'https://djangoapp.example.com/v1/pets?name=rudolf', headers=headers ) pprint(response.json()) ================================================ FILE: docs/settings_ref.rst ================================================ .. _settings: Settings Reference ================== .. _audience_setting: AUDIENCE -------- * **Default**: * **Type**: ``string`` or ``list`` **Required** Set this to the value of the ``aud`` claim your ADFS server sends back in the JWT token. You can lookup this value by executing the powershell command ``Get-AdfsRelyingPartyTrust`` on the ADFS server and taking the ``Identifier`` value. But beware, it doesn't match exactly if it's not a URL. Examples +--------------------------------------------------+------------------------------------------------------------+ | Relying Party Trust identifier | ``aud`` claim value | +==================================================+============================================================+ | your-RelyingPartyTrust-identifier | microsoft:identityserver:your-RelyingPartyTrust-identifier | +--------------------------------------------------+------------------------------------------------------------+ | https://adfs.yourcompany.com/adfs/services/trust | https://adfs.yourcompany.com/adfs/services/trust | +--------------------------------------------------+------------------------------------------------------------+ .. _block_guest_users_setting: BLOCK_GUEST_USERS ----------------- * **Default**: ``False`` * **Type**: ``boolean`` Whether guest users of your Azure AD is allowed to log into the site. This is validated by matching the ``http://schemas.microsoft.com/identity/claims/tenantid``-key in the claims towards the configured tenant. .. _boolean_claim_mapping_setting: BOOLEAN_CLAIM_MAPPING --------------------- * **Default**: ``None`` * **Type**: ``dictionary`` A dictionary of claim/field mappings that is used to set boolean fields on the user account in Django. The **key** represents user model field (e.g. ``first_name``) and the **value** represents the claim short name (e.g. ``given_name``). If the value is any of ``y, yes, t, true, on, 1``, the field will be set to ``True``. All other values, or the absence of the claim, will result in a value of ``False`` example .. code-block:: python AUTH_ADFS = { "BOOLEAN_CLAIM_MAPPING": {"is_staff": "user_is_staff", "is_superuser": "user_is_superuser"}, } .. NOTE:: You can find the short name for the claims you configure in the ADFS management console underneath **ADFS** ➜ **Service** ➜ **Claim Descriptions** CA_BUNDLE --------- * **Default**: ``True`` * **Type**: ``boolean`` or ``string`` The value of this setting is passed to the call to the ``Requests`` package when fetching the access token from ADFS. It allows you to control the webserver certificate verification of the ADFS server. ``True`` to use the default CA bundle of the ``requests`` package. ``/path/to/ca-bundle.pem`` allows you to specify a path to a CA bundle file. If your ADFS server uses a certificate signed by an enterprise root CA, you will need to specify the path to it's certificate here. ``False`` disables the certificate check. Have a look at the `Requests documentation `_ for more details. .. warning:: Do not set this value to ``False`` in a production setup. Because we load certain settings from the ADFS server, this might lead to a security issue. DNS hijacking for example might cause an attacker to inject his own access token signing certificate. .. _claim_mapping_setting: CLAIM_MAPPING ------------- * **Default**: ``None`` * **Type**: ``dictionary`` A dictionary of claim/field mappings that will be used to populate the user account in Django. The user's details will be set according to this setting upon each login. The **key** represents the user model field (e.g. ``first_name``) and the **value** represents the claim short name (e.g. ``given_name``). example .. code-block:: python AUTH_ADFS = { "CLAIM_MAPPING": {"first_name": "given_name", "last_name": "family_name", "email": "email"}, } The dictionary can also map extra details to the Django user account using an `Extension of the User model `_ Set a dictionary as value in the CLAIM_MAPPING setting with as key the name User model. You will need to make sure the related field exists before the user authenticates. This can be done by creating a receiver on the `post_save `_ signal that creates the related instance when the ``User`` instance is created. example .. code-block:: python 'CLAIM_MAPPING': {'first_name': 'given_name', 'last_name': 'family_name', 'email': 'upn', 'userprofile': { 'employee_id': 'employeeid' }} .. NOTE:: You can find the short name for the claims you configure in the ADFS management console underneath **ADFS** ➜ **Service** ➜ **Claim Descriptions** .. _client_id_setting: CLIENT_ID --------- * **Default**: * **Type**: ``dictionary`` **Required** Set this to the value you configured on your ADFS server as ``ClientId`` when executing the ``Add-AdfsClient`` command. You can lookup this value by executing the powershell command ``Get-AdfsClient`` on the ADFS server and taking the ``ClientId`` value. CLIENT_SECRET ------------- * **Default**: ``None`` * **Type**: ``string`` A Client secret is generated by ADFS server when executing the ``Add-AdfsClient`` command with the ``-GenerateClientSecret`` parameter. You can lookup this value by executing the powershell command ``Get-AdfsClient`` on the ADFS server and taking the ``ClientSecret`` value. CONFIG_RELOAD_INTERVAL ---------------------- * **Default**: ``24`` * **Unit**: hours * **Type**: ``integer`` When starting Django, some settings are retrieved from the ADFS metadata file or the OpenID Connect configuration on the ADFS server. Based on this information, certain configuration for this module is calculated. This setting determines the interval after which the configuration is reloaded. This allows to automatically follow the token signing certificate rollover on ADFS. .. _create_new_users_setting: CREATE_NEW_USERS ---------------- * **Default**: ``True`` * **Type**: ``boolean`` Determines whether users are created automatically if they do not exist. If set to ``False``, then you need to create your users before they can log in. DISABLE_SSO ----------- * **Default**: ``False`` * **Type**: ``boolean`` Setting this to ``True`` will globally disable the seamless single sign-on capability of ADFS. Forcing ADFS to prompt users for a username and password, instead of automatically logging them in with their current user. This allows users to use a different account then the one they are logged in with on their workstation. You can also selectively enable this setting by using ``...`` in a template instead of the regular ``...`` .. attention:: This does not work with ADFS 3.0 on windows 2012 because this setting requires OpenID Connect which is not supported on ADFS 3.0 JWT_LEEWAY ----------- * **Default**: ``0`` * **Type**: ``str`` Allows you to set a leeway of the JWT token. See the official `PyJWT `__ docs for more information. CUSTOM_FAILED_RESPONSE_VIEW -------------------------------- * **Default**: ``lambda`` * **Type**: ``str`` or ``callable`` Allows you to set a custom django function view to handle login failures. Can be a dot path to your Django function based view function or a callable. Callable must have the following method signature accepting ``error_message`` and ``status`` arguments: .. code-block:: python def failed_response(request, error_message, status): # Return an error message return render(request, 'myapp/login_failed.html', { 'error_message': error_message, }, status=status) GROUP_CLAIM ----------- Alias of ``GROUPS_CLAIM`` .. _groups_claim_setting: GROUPS_CLAIM ------------ * **Default**: ``group`` for ADFS or ``groups`` for Azure AD * **Type**: ``string`` Name of the claim in the JWT access token from ADFS that contains the groups the user is member of. If an entry in this claim matches a group configured in Django, the user will join it automatically. If using Azure AD and there are too many groups to fit in the JWT access token, the application will make a request to the Microsoft GraphQL API to find the groups. If you have many groups but only need a specific few, you can customize the request by overriding ``AdfsBaseBackend.get_group_memberships_from_ms_graph_params`` and specifying the `OData query parameters `_. Set this setting to ``None`` to disable automatic group handling. The group memberships of the user will not be touched. .. IMPORTANT:: If not set to ``None``, a user's group membership in Django will be reset to math this claim's value. If there's no value in the access token, the user will be removed from all groups. .. NOTE:: You can find the short name for the claims you configure in the ADFS management console underneath **ADFS** ➜ **Service** ➜ **Claim Descriptions** GROUP_TO_FLAG_MAPPING --------------------- * **Default**: ``None`` * **Type**: ``dictionary`` This settings allows you to set flags on a user based on his group membership in Active Directory. For example, if a user is a member of the group ``Django Staff``, you can automatically set the ``is_staff`` field of the user to ``True``. The **key** represents the boolean user model field (e.g. ``is_staff``) and the **value**, which can either be a single String or an array of Strings, represents the group(s) name (e.g. ``Django Staff``). example .. code-block:: python AUTH_ADFS = { "GROUP_TO_FLAG_MAPPING": {"is_staff": ["Django Staff", "Other Django Staff"], "is_superuser": "Django Admins"}, } .. NOTE:: The group doesn't need to exist in Django for this to work. This will work as long as it's in the groups claim in the access token. GUEST_USERNAME_CLAIM -------------------- * **Default**: ``None`` * **Type**: ``string`` When these criteria are met: 1. A ``guest_username_claim`` is configured 2. Token claims do not have the configured ``settings.USERNAME_CLAIM`` in it 3. The ``settings.BLOCK_GUEST_USERS`` is set to ``False`` 4. The claims ``tid`` does not match ``settings.TENANT_ID`` or claims ``idp`` does not match ``iss``. Then, the ``GUEST_USERNAME_CLAIM`` can be used to populate a username, when the ``USERNAME_CLAIM`` cannot be found in the claims. This can be useful when you want to use ``upn`` as a username claim for your own users, but some guest users (such as normal outlook users) don't have that claim. LOGIN_EXEMPT_URLS ----------------- * **Default**: ``None`` * **Type**: ``list`` When you activate the ``LoginRequiredMiddleware`` middleware, by default every page will redirect an unauthenticated user to the page configured in the Django setting ``LOGIN_URL``. If you have pages that should not trigger this redirect, add them to this setting as a list value. Every item it the list is interpreted as a regular expression. example .. code-block:: python AUTH_ADFS = { 'LOGIN_EXEMPT_URLS': [ '^$', '^api' ], } .. _mirror_group_setting: MIRROR_GROUPS ------------- * **Default**: ``False`` * **Type**: ``boolean`` This parameter will create groups from ADFS in the Django database if they do not exist already. ``True`` will create groups. ``False`` will not create any extra groups. .. IMPORTANT:: This parameter only has effect if GROUP_CLAIM is set to something other then ``None``. .. _relying_party_id_setting: RELYING_PARTY_ID ---------------- * **Default**: * **Type**: ``string`` **Required** Set this to the ``Relying party trust identifier`` value of the ``Relying Party Trust`` (2012) or ``Web application`` (2016) you configured in ADFS. You can lookup this value by executing the powershell command ``Get-AdfsRelyingPartyTrust`` (2012) or ``Get-AdfsWebApiApplication`` (2016) on the ADFS server and taking the ``Identifier`` value. RESOURCE -------- Alias for ``RELYING_PARTY_ID`` .. _retries_setting: RETRIES ------- * **Default**: ``3`` * **Type**: ``integer`` The number of time a request to the ADFS server is retried. It allows, in combination with :ref:`timeout_setting` to fine tune the behaviour of the connection to ADFS. SCOPES ------ * **Default**: ``[]`` * **Type**: ``list`` **Only used when you have v2 AzureAD config** SERVER ------ * **Default**: * **Type**: ``string`` **Required** when your identity provider is an on premises ADFS server. Only one of ``SERVER`` or ``TENANT_ID`` can be set. The FQDN of the ADFS server you want users to authenticate against. SETTINGS_CLASS -------------- * **Default**: ``django_auth_adfs.config.Settings`` * **Type**: ``string`` By default, django-auth-adfs reads the configuration from the Django setting ``AUTH_ADFS``. You can provide the configuration in a custom implementation and point to it by using the ``SETTINGS_CLASS`` setting: .. code-block:: python # in myapp.adfs.config class CustomSettings: SERVER = 'bar' AUDIENCE = 'foo' ... # in settings.py AUTH_ADFS = { 'SETTINGS_CLASS': 'myapp.adfs.config.CustomSettings', # other settings are not needed } The value must be an importable dotted Python path, and the imported object must be callable with no arguments to initialize. Use cases are storing configuration in database so an administrator can edit the configuration in an admin interface. .. _tenant_id_setting: TENANT_ID --------- * **Default**: * **Type**: ``string`` **Required** when your identity provider is an Azure AD instance. Only one of ``TENANT_ID`` or ``SERVER`` can be set. The FQDN of the ADFS server you want users to authenticate against. .. _timeout_setting: TIMEOUT ------- * **Default**: ``5`` * **Unit**: seconds * **Type**: ``integer`` The timeout in seconds for every request made to the ADFS server. It's passed on as the ``timeout`` parameter to the underlying calls to the `requests `__ library. It allows, in combination with :ref:`retries_setting` to fine tune the behaviour of the connection to ADFS. .. _username_claim_setting: USERNAME_CLAIM -------------- * **Default**: ``winaccountname`` for ADFS or ``upn`` for Azure AD. * **Type**: ``string`` Name of the claim sent in the JWT token from ADFS that contains the username. If the user doesn't exist yet, this field will be used as it's username. The value of the claim must be a unique value. No 2 users should ever have the same value. .. warning:: You shouldn't need to set this value for ADFS or Azure AD unless you use custom user models. Because ``winaccountname`` maps to the ``sAMAccountName`` on Active Directory, which is guaranteed to be unique. The same for Azure AD where ``upn`` maps to the ``UserPrincipleName``, which is unique on Azure AD. .. NOTE:: You can find the short name for the claims you configure in the ADFS management console underneath **ADFS** ➜ **Service** ➜ **Claim Descriptions** .. _version_setting: VERSION -------------- * **Default**: ``v1.0`` * **Type**: ``string`` Version of the Azure Active Directory endpoint version. By default it is set to ``v1.0``. At the time of writing this documentation, it can also be set to ``v2.0``. For new projects, ``v2.0`` is recommended. ``v1.0`` is kept as a default for backwards compatibility. PROXIES ------- * **Default**: ``None`` * **Type**: ``dict`` An optional proxy for all communication with the server. Example: ``{'http': '10.0.0.1', 'https': '10.0.0.2'}`` See the `requests documentation `__ for more information. ================================================ FILE: docs/signals.rst ================================================ Django Signals ================ **django-auth-adfs** uses Django `Signals ` to allow the application to listen for and execute custom logic at certain points in the authentication process. Currently, the following signals are supported: * ``post_authenticate``: sent after a user has been authenticated through any subclass of ``AdfsBaseBackend``. The signal is sent after all other processing is done, e.g. mapping claims and groups and creating the user in Django (if :ref:`the CREATE_NEW_USERS setting ` is enabled). In addition to the sender, the signal includes the user object, the claims dictionary, and the ADFS response as arguments for the signal handler: * ``sender`` (``AdfsBaseBackend``): the backend instance from which the signal was triggered. * ``user`` (Django user class): the user object that was authenticated. * ``claims`` (``dict``): the decoded access token JWT, which contains all claims sent from the identity provider. * ``adfs_response`` (``dict|None``): used in the ``AdfsAuthCodeBackend`` to provide the full response received from the server when exchanging an authorization code for an access token. To use a signal in your application: .. code-block:: python from django.dispatch import receiver from django_auth_adfs.signals import post_authenticate @receiver(post_authenticate) def handle_post_authenticate(sender, user, claims, adfs_response=None, **kwargs): user.do_post_auth_steps(claims, adfs_response) ================================================ FILE: docs/troubleshooting.rst ================================================ Troubleshooting =============== Turn on Django debug logging ---------------------------- If you run into any problems, set the logging level in Django to DEBUG. You can do this by adding the configuration below to your ``settings.py`` You can see this logging in your console, or in you web server log if you're using something like Apache with mod_wsgi. More details about logging in Django can be found in `the official Django documentation `_ .. code-block:: python LOGGING = { 'version': 1, 'disable_existing_loggers': False, 'formatters': { 'verbose': { 'format': '%(levelname)s %(asctime)s %(name)s %(message)s' }, }, 'handlers': { 'console': { 'class': 'logging.StreamHandler', 'formatter': 'verbose' }, }, 'loggers': { 'django_auth_adfs': { 'handlers': ['console'], 'level': 'DEBUG', }, }, } Run Django with warnings enabled -------------------------------- Start the python interpreter that runs you Django with the ``-Wd`` parameter. This will show warnings that are otherwise suppressed. .. code-block:: bash python -Wd manage.py runserver Have a look at the demo project ------------------------------- There's an simple demo project available in the ``/demo`` folder and in the **demo** chapter of the documentation. If you compare the files in the ``adfs`` folder with those in the ``formsbased`` folder, you'll see what needs to be changed in a standard Django project to enable ADFS authentication. Besides that, there are a couple of PowerShell scripts available that are used while provisioning the ADFS server for the demo. you can find them in the ``/vagrant`` folder in this repository. They might be useful to figure out what is wrong with the configuration of your ADFS server. **Note that they are only meant for getting a demo running. By no means are they meant to configure your ADFS server.** ================================================ FILE: manage.py ================================================ #!/usr/bin/env python import os import sys if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") from django.core.management import execute_from_command_line execute_from_command_line(sys.argv) ================================================ FILE: pyproject.toml ================================================ [tool.poetry] name = 'django-auth-adfs' version = "1.16.0" # Remember to also change __init__.py version description = 'A Django authentication backend for Microsoft ADFS and AzureAD' authors = ['Joris Beckers '] maintainers = ['Jonas Krüger Svensson ', 'Sondre Lillebø Gundersen '] license = 'BSD-1-Clause' homepage = 'https://github.com/snok/django-auth-adfs' repository = 'https://github.com/snok/django-auth-adfs' documentation = 'https://django-auth-adfs.readthedocs.io/en/latest' keywords = ['django', 'authentication', 'adfs', 'azure', 'ad', 'oauth2'] readme = 'README.rst' classifiers = [ 'Environment :: Web Environment', 'Framework :: Django :: 4.2', 'Framework :: Django :: 5.0', 'Framework :: Django :: 5.1', 'Framework :: Django :: 5.2', 'Framework :: Django :: 6.0', 'Intended Audience :: Developers', 'Intended Audience :: End Users/Desktop', 'Operating System :: OS Independent', 'License :: OSI Approved :: BSD License', 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.13', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 'Topic :: Internet :: WWW/HTTP :: WSGI', 'Topic :: Software Development :: Libraries :: Application Frameworks', 'Topic :: Software Development :: Libraries :: Python Modules', 'Development Status :: 5 - Production/Stable', ] [tool.poetry.dependencies] python = '^3.9' django = [ { version = '^4.2', python = '>=3.9 <3.10' }, { version = '^4.2 || ^5 || ^6', python = '>=3.10' }, ] requests = '*' urllib3 = '*' cryptography = '*' PyJWT = "*" [tool.poetry.group.dev.dependencies] responses = '*' mock = '*' coverage = '*' djangorestframework = '*' django-filter = "*" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" ================================================ FILE: setup.cfg ================================================ [flake8] max-line-length = 120 exclude = docs/* .venv ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/custom_config.py ================================================ class Settings(object): RETRIES = 1 CA_BUNDLE = False def __init__(self): self.SERVER = 'custom-server' ================================================ FILE: tests/mock_files/FederationMetadata.xml ================================================ -- -- -- -- E-Mail Address The e-mail address of the user Given Name The given name of the user Name The unique name of the user UPN The user principal name (UPN) of the user Common Name The common name of the user AD FS 1.x E-Mail Address The e-mail address of the user when interoperating with AD FS 1.1 or AD FS 1.0 Group A group that the user is a member of AD FS 1.x UPN The UPN of the user when interoperating with AD FS 1.1 or AD FS 1.0 Role A role that the user has Surname The surname of the user PPID The private identifier of the user Name ID The SAML name identifier of the user Authentication time stamp Used to display the time and date that the user was authenticated Authentication method The method used to authenticate the user Deny only group SID The deny-only group SID of the user Deny only primary SID The deny-only primary SID of the user Deny only primary group SID The deny-only primary group SID of the user Group SID The group SID of the user Primary group SID The primary group SID of the user Primary SID The primary SID of the user Windows account name The domain account name of the user in the form of domain\user Is Registered User User is registered to use this device Device Identifier Identifier of the device Device Registration Identifier Identifier for Device Registration Device Registration DisplayName Display name of Device Registration Device OS type OS type of the device Device OS Version OS version of the device Is Managed Device Device is managed by a management service Forwarded Client IP IP address of the user Client Application Type of the Client Application Client User Agent Device type the client is using to access the application Client IP IP address of the client Endpoint Path Absolute Endpoint path which can be used to determine active versus passive clients Proxy DNS name of the federation server proxy that passed the request Application Identifier Identifier for the Relying Party Application policies Application policies of the certificate Authority Key Identifier The Authority Key Identifier extension of the certificate that signed an issued certificate Basic Constraint One of the basic constraints of the certificate Enhanced Key Usage Describes one of the enhanced key usages of the certificate Issuer The name of the certificate authority that issued the X.509 certificate Issuer Name The distinguished name of the certificate issuer Key Usage One of the key usages of the certificate Not After Date in local time after which a certificate is no longer valid Not Before The date in local time on which a certificate becomes valid Certificate Policies The policies under which the certificate has been issued Public Key Public Key of the certificate Certificate Raw Data The raw data of the certificate Subject Alternative Name One of the alternative names of the certificate Serial Number The serial number of a certificate Signature Algorithm The algorithm used to create the signature of a certificate Subject The subject from the certificate Subject Key Identifier Describes the subject key identifier of the certificate Subject Name The subject distinguished name from a certificate V2 Template Name The name of the version 2 certificate template used when issuing or renewing a certificate. The extension is Microsoft specific. V1 Template Name The name of the version 1 certificate template used when issuing or renewing a certificate. The extension is Microsoft specific. Thumbprint Thumbprint of the certificate X.509 Version The X.509 format version of a certificate Inside Corporate Network Used to indicate if a request originated inside corporate network Password Expiration Time Used to display the time when the password expires Password Expiration Days Used to display the number of days to password expiry Update Password URL Used to display the web address of update password service Authentication Methods References Used to indicate all authentication methods used to authenticate the user Client Request ID Identifier for a user session
https://adfs.example.com/adfs/services/trust/2005/issuedtokenmixedasymmetricbasic256
https://adfs.example.com/adfs/services/trust/2005/issuedtokenmixedsymmetricbasic256
https://adfs.example.com/adfs/services/trust/13/issuedtokenmixedasymmetricbasic256
https://adfs.example.com/adfs/services/trust/13/issuedtokenmixedsymmetricbasic256
https://adfs.example.com/adfs/ls/
http://adfs.example.com/adfs/services/trustttt
https://adfs.example.com/adfs/services/trust/2005/issuedtokenmixedasymmetricbasic256
https://adfs.example.com/adfs/ls/
REPLACE_WITH_CERT_A REPLACE_WITH_CERT_B E-Mail Address The e-mail address of the user Given Name The given name of the user Name The unique name of the user UPN The user principal name (UPN) of the user Common Name The common name of the user AD FS 1.x E-Mail Address The e-mail address of the user when interoperating with AD FS 1.1 or AD FS 1.0 Group A group that the user is a member of AD FS 1.x UPN The UPN of the user when interoperating with AD FS 1.1 or AD FS 1.0 Role A role that the user has Surname The surname of the user PPID The private identifier of the user Name ID The SAML name identifier of the user Authentication time stamp Used to display the time and date that the user was authenticated Authentication method The method used to authenticate the user Deny only group SID The deny-only group SID of the user Deny only primary SID The deny-only primary SID of the user Deny only primary group SID The deny-only primary group SID of the user Group SID The group SID of the user Primary group SID The primary group SID of the user Primary SID The primary SID of the user Windows account name The domain account name of the user in the form of domain\user Is Registered User User is registered to use this device Device Identifier Identifier of the device Device Registration Identifier Identifier for Device Registration Device Registration DisplayName Display name of Device Registration Device OS type OS type of the device Device OS Version OS version of the device Is Managed Device Device is managed by a management service Forwarded Client IP IP address of the user Client Application Type of the Client Application Client User Agent Device type the client is using to access the application Client IP IP address of the client Endpoint Path Absolute Endpoint path which can be used to determine active versus passive clients Proxy DNS name of the federation server proxy that passed the request Application Identifier Identifier for the Relying Party Application policies Application policies of the certificate Authority Key Identifier The Authority Key Identifier extension of the certificate that signed an issued certificate Basic Constraint One of the basic constraints of the certificate Enhanced Key Usage Describes one of the enhanced key usages of the certificate Issuer The name of the certificate authority that issued the X.509 certificate Issuer Name The distinguished name of the certificate issuer Key Usage One of the key usages of the certificate Not After Date in local time after which a certificate is no longer valid Not Before The date in local time on which a certificate becomes valid Certificate Policies The policies under which the certificate has been issued Public Key Public Key of the certificate Certificate Raw Data The raw data of the certificate Subject Alternative Name One of the alternative names of the certificate Serial Number The serial number of a certificate Signature Algorithm The algorithm used to create the signature of a certificate Subject The subject from the certificate Subject Key Identifier Describes the subject key identifier of the certificate Subject Name The subject distinguished name from a certificate V2 Template Name The name of the version 2 certificate template used when issuing or renewing a certificate. The extension is Microsoft specific. V1 Template Name The name of the version 1 certificate template used when issuing or renewing a certificate. The extension is Microsoft specific. Thumbprint Thumbprint of the certificate X.509 Version The X.509 format version of a certificate Inside Corporate Network Used to indicate if a request originated inside corporate network Password Expiration Time Used to display the time when the password expires Password Expiration Days Used to display the number of days to password expiry Update Password URL Used to display the web address of update password service Authentication Methods References Used to indicate all authentication methods used to authenticate the user Client Request ID Identifier for a user session
https://adfs.example.com/adfs/services/trust/2005/certificatemixed
https://adfs.example.com/adfs/services/trust/mex
https://adfs.example.com/adfs/ls/
-- -- urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress urn:oasis:names:tc:SAML:2.0:nameid-format:persistent urn:oasis:names:tc:SAML:2.0:nameid-format:transient -- -- urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress urn:oasis:names:tc:SAML:2.0:nameid-format:persistent urn:oasis:names:tc:SAML:2.0:nameid-format:transient
================================================ FILE: tests/mock_files/adfs-openid-configuration.json ================================================ { "issuer": "https://adfs.example.com/adfs", "authorization_endpoint": "https://adfs.example.com/adfs/oauth2/authorize/", "token_endpoint": "https://adfs.example.com/adfs/oauth2/token/", "jwks_uri": "https://adfs.example.com/adfs/discovery/keys", "token_endpoint_auth_methods_supported": [ "client_secret_post", "client_secret_basic", "private_key_jwt", "windows_client_authentication" ], "response_types_supported": [ "code", "id_token", "code id_token", "id_token token", "code token", "code id_token token" ], "response_modes_supported": [ "query", "fragment", "form_post" ], "grant_types_supported": [ "authorization_code", "refresh_token", "client_credentials", "urn:ietf:params:oauth:grant-type:jwt-bearer", "implicit", "password", "srv_challenge" ], "subject_types_supported": [ "pairwise" ], "scopes_supported": [ "user_impersonation", "openid", "winhello_cert", "aza", "vpn_cert", "profile", "allatclaims", "email", "logon_cert" ], "id_token_signing_alg_values_supported": [ "RS256" ], "token_endpoint_auth_signing_alg_values_supported": [ "RS256" ], "access_token_issuer": "http://adfs.example.com/adfs/services/trust", "claims_supported": [ "aud", "iss", "iat", "exp", "auth_time", "nonce", "at_hash", "c_hash", "sub", "upn", "unique_name", "pwd_url", "pwd_exp", "sid" ], "microsoft_multi_refresh_token": true, "userinfo_endpoint": "https://adfs.example.com/adfs/userinfo", "capabilities": [], "end_session_endpoint": "https://adfs.example.com/adfs/oauth2/logout", "as_access_token_token_binding_supported": true, "as_refresh_token_token_binding_supported": true, "resource_access_token_token_binding_supported": true, "op_id_token_token_binding_supported": true, "rp_id_token_token_binding_supported": true, "frontchannel_logout_supported": true, "frontchannel_logout_session_supported": true } ================================================ FILE: tests/mock_files/azure-openid-configuration-v2.json ================================================ { "authorization_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/v2.0/authorize", "token_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/v2.0/token", "token_endpoint_auth_methods_supported": [ "client_secret_post", "private_key_jwt", "client_secret_basic" ], "jwks_uri": "https://login.microsoftonline.com/common/discovery/keys", "response_modes_supported": [ "query", "fragment", "form_post" ], "subject_types_supported": [ "pairwise" ], "id_token_signing_alg_values_supported": [ "RS256" ], "http_logout_supported": true, "frontchannel_logout_supported": true, "end_session_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/v2.0/logout", "response_types_supported": [ "code", "id_token", "code id_token", "token id_token", "token" ], "scopes_supported": [ "openid" ], "issuer": "https://sts.windows.net/01234567-89ab-cdef-0123-456789abcdef/", "claims_supported": [ "sub", "iss", "cloud_instance_name", "cloud_instance_host_name", "cloud_graph_host_name", "msgraph_host", "aud", "exp", "iat", "auth_time", "acr", "amr", "nonce", "email", "given_name", "family_name", "nickname" ], "microsoft_multi_refresh_token": true, "check_session_iframe": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/v2.0/checksession", "userinfo_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/openid/userinfo", "tenant_region_scope": "EU", "cloud_instance_name": "microsoftonline.com", "cloud_graph_host_name": "graph.windows.net", "msgraph_host": "graph.microsoft.com", "rbac_url": "https://pas.windows.net" } ================================================ FILE: tests/mock_files/azure-openid-configuration.json ================================================ { "authorization_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/authorize", "token_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/token", "token_endpoint_auth_methods_supported": [ "client_secret_post", "private_key_jwt", "client_secret_basic" ], "jwks_uri": "https://login.microsoftonline.com/common/discovery/keys", "response_modes_supported": [ "query", "fragment", "form_post" ], "subject_types_supported": [ "pairwise" ], "id_token_signing_alg_values_supported": [ "RS256" ], "http_logout_supported": true, "frontchannel_logout_supported": true, "end_session_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/logout", "response_types_supported": [ "code", "id_token", "code id_token", "token id_token", "token" ], "scopes_supported": [ "openid" ], "issuer": "https://sts.windows.net/01234567-89ab-cdef-0123-456789abcdef/", "claims_supported": [ "sub", "iss", "cloud_instance_name", "cloud_instance_host_name", "cloud_graph_host_name", "msgraph_host", "aud", "exp", "iat", "auth_time", "acr", "amr", "nonce", "email", "given_name", "family_name", "nickname" ], "microsoft_multi_refresh_token": true, "check_session_iframe": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/checksession", "userinfo_endpoint": "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/openid/userinfo", "tenant_region_scope": "EU", "cloud_instance_name": "microsoftonline.com", "cloud_graph_host_name": "graph.windows.net", "msgraph_host": "graph.microsoft.com", "rbac_url": "https://pas.windows.net" } ================================================ FILE: tests/models.py ================================================ from django.db import models from django.contrib.auth.models import User class Profile(models.Model): user = models.OneToOneField(User, related_name="profile", on_delete=models.CASCADE) employee_id = models.IntegerField(blank=True, null=True) ================================================ FILE: tests/settings.py ================================================ SECRET_KEY = 'secret' DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:', 'USER': '', 'PASSWORD': '', 'HOST': '', 'PORT': '', } } TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ 'django.template.context_processors.debug', 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', ], }, 'DIRS': 'templates' }, ] MIDDLEWARE = ( 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django_auth_adfs.middleware.LoginRequiredMiddleware', ) INSTALLED_APPS = ( 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'django_auth_adfs', 'tests', ) AUTHENTICATION_BACKENDS = ( "django.contrib.auth.backends.ModelBackend", 'django_auth_adfs.backend.AdfsAuthCodeBackend', 'django_auth_adfs.backend.AdfsAccessTokenBackend', ) ROOT_URLCONF = 'tests.urls' STATIC_ROOT = '/tmp/' # Dummy STATIC_URL = '/static/' AUTH_ADFS = { "SERVER": "adfs.example.com", "CLIENT_ID": "your-configured-client-id", "RELYING_PARTY_ID": "your-adfs-RPT-name", "AUDIENCE": "microsoft:identityserver:your-RelyingPartyTrust-identifier", "CA_BUNDLE": "/path/to/ca-bundle.pem", "CLAIM_MAPPING": {"first_name": "given_name", "last_name": "family_name", "email": "email"}, "BOOLEAN_CLAIM_MAPPING": {"is_staff": "user_is_staff", "is_superuser": "user_is_superuser"}, "CONFIG_RELOAD_INTERVAL": 0, # Always reload settings } LOGIN_URL = "django_auth_adfs:login" LOGIN_REDIRECT_URL = "/" ================================================ FILE: tests/test_authentication.py ================================================ import base64 from django_auth_adfs.exceptions import MFARequired try: from urllib.parse import parse_qs, urlparse except ImportError: # Python 2.7 from urlparse import urlparse, parse_qs from copy import deepcopy from django.contrib.auth.models import Group, User from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db.models.signals import post_save from django.test import RequestFactory, TestCase from mock import Mock, patch from django_auth_adfs import signals from django_auth_adfs.backend import AdfsAuthCodeBackend from django_auth_adfs.config import ProviderConfig, Settings from .models import Profile from .utils import mock_adfs class AuthenticationTests(TestCase): def setUp(self): Group.objects.create(name='group1') Group.objects.create(name='group2') Group.objects.create(name='group3') self.request = RequestFactory().get('/oauth2/callback') self.signal_handler = Mock() signals.post_authenticate.connect(self.signal_handler) @mock_adfs("2012") def test_post_authenticate_signal_send(self): backend = AdfsAuthCodeBackend() backend.authenticate(self.request, authorization_code="dummycode") self.assertEqual(self.signal_handler.call_count, 1) @mock_adfs("2012") def test_with_auth_code_2012(self): backend = AdfsAuthCodeBackend() user = backend.authenticate(self.request, authorization_code="dummycode") self.assertIsInstance(user, User) self.assertEqual(user.first_name, "John") self.assertEqual(user.last_name, "Doe") self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(len(user.groups.all()), 2) self.assertEqual(user.groups.all()[0].name, "group1") self.assertEqual(user.groups.all()[1].name, "group2") @mock_adfs("2016") def test_with_auth_code_2016(self): backend = AdfsAuthCodeBackend() user = backend.authenticate(self.request, authorization_code="dummycode") self.assertIsInstance(user, User) self.assertEqual(user.first_name, "John") self.assertEqual(user.last_name, "Doe") self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(len(user.groups.all()), 2) self.assertEqual(user.groups.all()[0].name, "group1") self.assertEqual(user.groups.all()[1].name, "group2") @mock_adfs("2016", mfa_error=True) def test_mfa_error_backends(self): with self.assertRaises(MFARequired): backend = AdfsAuthCodeBackend() backend.authenticate(self.request, authorization_code="dummycode") @mock_adfs("azure") def test_with_auth_code_azure(self): from django_auth_adfs.config import django_settings settings = deepcopy(django_settings) del settings.AUTH_ADFS["SERVER"] settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" with patch("django_auth_adfs.config.django_settings", settings): with patch("django_auth_adfs.config.settings", Settings()): with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): backend = AdfsAuthCodeBackend() user = backend.authenticate(self.request, authorization_code="dummycode") self.assertIsInstance(user, User) self.assertEqual(user.first_name, "John") self.assertEqual(user.last_name, "Doe") self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(len(user.groups.all()), 2) self.assertEqual(user.groups.all()[0].name, "group1") self.assertEqual(user.groups.all()[1].name, "group2") @mock_adfs("azure", guest=True) def test_with_auth_code_azure_guest_block(self): from django_auth_adfs.config import django_settings settings = deepcopy(django_settings) del settings.AUTH_ADFS["SERVER"] settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" settings.AUTH_ADFS["BLOCK_GUEST_USERS"] = True # Patch audience since we're patching django_auth_adfs.backend.settings to load Settings() as well settings.AUTH_ADFS["AUDIENCE"] = 'microsoft:identityserver:your-RelyingPartyTrust-identifier' with patch("django_auth_adfs.config.django_settings", settings): with patch('django_auth_adfs.backend.settings', Settings()): with patch("django_auth_adfs.config.settings", Settings()): with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): with self.assertRaises(PermissionDenied, msg=''): backend = AdfsAuthCodeBackend() _ = backend.authenticate(self.request, authorization_code="dummycode") @mock_adfs("azure", guest=True) def test_with_auth_code_azure_guest_no_block(self): from django_auth_adfs.config import django_settings settings = deepcopy(django_settings) del settings.AUTH_ADFS["SERVER"] settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" settings.AUTH_ADFS["BLOCK_GUEST_USERS"] = False # Patch audience since we're patching django_auth_adfs.backend.settings to load Settings() as well settings.AUTH_ADFS["AUDIENCE"] = 'microsoft:identityserver:your-RelyingPartyTrust-identifier' with patch("django_auth_adfs.config.django_settings", settings): with patch('django_auth_adfs.backend.settings', Settings()): with patch("django_auth_adfs.config.settings", Settings()): with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): backend = AdfsAuthCodeBackend() user = backend.authenticate(self.request, authorization_code="dummycode") self.assertIsInstance(user, User) self.assertEqual(user.first_name, "John") self.assertEqual(user.last_name, "Doe") self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(len(user.groups.all()), 2) self.assertEqual(user.groups.all()[0].name, "group1") self.assertEqual(user.groups.all()[1].name, "group2") @mock_adfs("azure", version='v2.0') def test_version_two_endpoint_calls_correct_url(self): from django_auth_adfs.config import django_settings settings = deepcopy(django_settings) del settings.AUTH_ADFS["SERVER"] settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" settings.AUTH_ADFS["VERSION"] = 'v2.0' # Patch audience since we're patching django_auth_adfs.backend.settings to load Settings() as well with patch("django_auth_adfs.config.django_settings", settings): with patch('django_auth_adfs.backend.settings', Settings()): with patch("django_auth_adfs.config.settings", Settings()): with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): backend = AdfsAuthCodeBackend() user = backend.authenticate(self.request, authorization_code="dummycode") self.assertIsInstance(user, User) self.assertEqual(user.first_name, "John") self.assertEqual(user.last_name, "Doe") self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(len(user.groups.all()), 2) self.assertEqual(user.groups.all()[0].name, "group1") self.assertEqual(user.groups.all()[1].name, "group2") @mock_adfs("2016") def test_empty(self): backend = AdfsAuthCodeBackend() self.assertIsNone(backend.authenticate(self.request)) @mock_adfs("2016") def test_group_claim(self): backend = AdfsAuthCodeBackend() with patch("django_auth_adfs.backend.settings.GROUPS_CLAIM", "nonexisting"): user = backend.authenticate(self.request, authorization_code="dummycode") self.assertIsInstance(user, User) self.assertEqual(user.first_name, "John") self.assertEqual(user.last_name, "Doe") self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(len(user.groups.all()), 0) @mock_adfs("2016") def test_no_group_claim(self): backend = AdfsAuthCodeBackend() with patch("django_auth_adfs.backend.settings.GROUPS_CLAIM", None): user = backend.authenticate(self.request, authorization_code="dummycode") self.assertIsInstance(user, User) self.assertEqual(user.first_name, "John") self.assertEqual(user.last_name, "Doe") self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(len(user.groups.all()), 0) @mock_adfs("2016") def test_group_claim_with_mirror_groups(self): # Remove one group Group.objects.filter(name="group1").delete() backend = AdfsAuthCodeBackend() with patch("django_auth_adfs.backend.settings.MIRROR_GROUPS", True): user = backend.authenticate(self.request, authorization_code="dummycode") self.assertIsInstance(user, User) self.assertEqual(user.first_name, "John") self.assertEqual(user.last_name, "Doe") self.assertEqual(user.email, "john.doe@example.com") # group1 is restored group_names = user.groups.order_by("name").values_list("name", flat=True) self.assertSequenceEqual(group_names, ['group1', 'group2']) @mock_adfs("2016") def test_group_claim_without_mirror_groups(self): # Remove one group Group.objects.filter(name="group1").delete() backend = AdfsAuthCodeBackend() with patch("django_auth_adfs.backend.settings.MIRROR_GROUPS", False): user = backend.authenticate(self.request, authorization_code="dummycode") self.assertIsInstance(user, User) self.assertEqual(user.first_name, "John") self.assertEqual(user.last_name, "Doe") self.assertEqual(user.email, "john.doe@example.com") # User is not added to group1 because the group doesn't exist group_names = user.groups.values_list("name", flat=True) self.assertSequenceEqual(group_names, ['group2']) @mock_adfs("2016", empty_keys=True) def test_empty_keys(self): backend = AdfsAuthCodeBackend() with patch("django_auth_adfs.config.provider_config.signing_keys", []): self.assertRaises(PermissionDenied, backend.authenticate, self.request, authorization_code='testcode') @mock_adfs("2016") def test_group_removal(self): user, created = User.objects.get_or_create(**{ User.USERNAME_FIELD: "testuser" }) group = Group.objects.get(name="group3") user.groups.add(group) user.set_unusable_password() user.save() self.assertEqual(user.groups.all()[0].name, "group3") self.assertEqual(len(user.groups.all()), 1) backend = AdfsAuthCodeBackend() user = backend.authenticate(self.request, authorization_code="dummycode") self.assertIsInstance(user, User) self.assertEqual(user.first_name, "John") self.assertEqual(user.last_name, "Doe") self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(len(user.groups.all()), 2) self.assertEqual(user.groups.all()[0].name, "group1") self.assertEqual(user.groups.all()[1].name, "group2") @mock_adfs("2016") def test_group_removal_overlap(self): user, created = User.objects.get_or_create(**{ User.USERNAME_FIELD: "testuser" }) group_one = Group.objects.get(name="group1") group_three = Group.objects.get(name="group3") user.groups.add(group_one, group_three) user.set_unusable_password() user.save() self.assertEqual(user.groups.all()[0].name, "group1") self.assertEqual(user.groups.all()[1].name, "group3") self.assertEqual(len(user.groups.all()), 2) backend = AdfsAuthCodeBackend() user = backend.authenticate(self.request, authorization_code="dummycode") self.assertIsInstance(user, User) self.assertEqual(user.first_name, "John") self.assertEqual(user.last_name, "Doe") self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(len(user.groups.all()), 2) self.assertEqual(user.groups.all()[0].name, "group1") self.assertEqual(user.groups.all()[1].name, "group2") @mock_adfs("2016") def test_group_to_flag_mapping(self): group_to_flag_mapping = { "is_staff": ["group1", "group4"], "is_superuser": "group2", } with patch("django_auth_adfs.backend.settings.GROUP_TO_FLAG_MAPPING", group_to_flag_mapping): with patch("django_auth_adfs.backend.settings.BOOLEAN_CLAIM_MAPPING", {}): backend = AdfsAuthCodeBackend() user = backend.authenticate(self.request, authorization_code="dummycode") self.assertIsInstance(user, User) self.assertEqual(user.first_name, "John") self.assertEqual(user.last_name, "Doe") self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(len(user.groups.all()), 2) self.assertTrue(user.is_staff) self.assertTrue(user.is_superuser) @mock_adfs("2016") def test_boolean_claim_mapping(self): boolean_claim_mapping = { "is_superuser": "user_is_superuser", } with patch("django_auth_adfs.backend.settings.BOOLEAN_CLAIM_MAPPING", boolean_claim_mapping): backend = AdfsAuthCodeBackend() user = backend.authenticate(self.request, authorization_code="dummycode") self.assertIsInstance(user, User) self.assertEqual(user.first_name, "John") self.assertEqual(user.last_name, "Doe") self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(len(user.groups.all()), 2) self.assertFalse(user.is_staff) self.assertTrue(user.is_superuser) @mock_adfs("2016") def test_extended_model_claim_mapping_missing_instance(self): claim_mapping = { "first_name": "given_name", "last_name": "family_name", "email": "email", "profile": { "employee_id": "custom_employee_id", }, } with patch("django_auth_adfs.backend.settings.CLAIM_MAPPING", claim_mapping): backend = AdfsAuthCodeBackend() user = backend.authenticate(self.request, authorization_code="dummycode") self.assertIsInstance(user, User) self.assertEqual(user.first_name, "John") self.assertEqual(user.last_name, "Doe") self.assertEqual(user.email, "john.doe@example.com") with self.assertRaises(ObjectDoesNotExist): user.profile # noqa @mock_adfs("2016") def test_extended_model_claim_mapping(self): def create_profile(sender, instance, created, **kwargs): """Create a profile for any user that's created.""" if created: Profile.objects.create(user=instance) post_save.connect(create_profile, sender=User) claim_mapping = { "first_name": "given_name", "last_name": "family_name", "email": "email", "profile": { "employee_id": "custom_employee_id", }, } with patch("django_auth_adfs.backend.settings.CLAIM_MAPPING", claim_mapping): backend = AdfsAuthCodeBackend() user = backend.authenticate(self.request, authorization_code="dummycode") self.assertIsInstance(user, User) self.assertEqual(user.first_name, "John") self.assertEqual(user.last_name, "Doe") self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(user.profile.employee_id, 182) post_save.disconnect(create_profile, sender=User) @mock_adfs("2016") def test_authentication(self): response = self.client.get("/oauth2/callback", {'code': 'testcode'}) self.assertEqual(response.status_code, 302) self.assertEqual(response['Location'], "/") @mock_adfs("2016") def test_mfa_error(self): with patch('django_auth_adfs.views.authenticate') as mock_auth: mock_auth.side_effect = MFARequired('Mock error') response = self.client.get("/oauth2/callback", {'code': 'testcode'}) self.assertEqual(response.status_code, 302) self.assertEqual( response['Location'], "https://adfs.example.com/adfs/oauth2/authorize/?response_type=code&" "client_id=your-configured-client-id&resource=your-adfs-RPT-name&" "redirect_uri=http%3A%2F%2Ftestserver%2Foauth2%2Fcallback&state=Lw%3D%3D&scope=openid&" "amr_values=ngcmfa" ) @mock_adfs("2016") def test_callback_redir(self): state = base64.urlsafe_b64encode("/test/".encode()) response = self.client.get("/oauth2/callback", {'code': 'testcode', "state": state}) self.assertEqual(response.status_code, 302) self.assertEqual(response['Location'], "/test/") @mock_adfs("2016") def test_missing_code(self): response = self.client.get("/oauth2/callback") self.assertEqual(response.status_code, 400) @mock_adfs("2016") def test_login_redir(self): response = self.client.get("/test/") self.assertEqual(response.status_code, 302) self.assertEqual(response["Location"], '/oauth2/login?next=/test/') @mock_adfs("2012") def test_oauth_redir_2012(self): response = self.client.get("/oauth2/login?next=/test/") self.assertEqual(response.status_code, 302) redir = urlparse(response["Location"]) qs = parse_qs(redir.query) sq_expected = { 'client_id': ['your-configured-client-id'], 'state': ['L3Rlc3Qv'], 'response_type': ['code'], 'resource': ['your-adfs-RPT-name'], 'redirect_uri': ['http://testserver/oauth2/callback'] } self.assertEqual(redir.scheme, 'https') self.assertEqual(redir.hostname, 'adfs.example.com') self.assertEqual(redir.path.rstrip("/"), '/adfs/oauth2/authorize') self.assertEqual(qs, sq_expected) @mock_adfs("2016") def test_oauth_redir_2016(self): response = self.client.get("/oauth2/login?next=/test/") self.assertEqual(response.status_code, 302) redir = urlparse(response["Location"]) qs = parse_qs(redir.query) qs_expected = { 'scope': ['openid'], 'client_id': ['your-configured-client-id'], 'state': ['L3Rlc3Qv'], 'response_type': ['code'], 'resource': ['your-adfs-RPT-name'], 'redirect_uri': ['http://testserver/oauth2/callback'] } self.assertEqual(redir.scheme, 'https') self.assertEqual(redir.hostname, 'adfs.example.com') self.assertEqual(redir.path.rstrip("/"), '/adfs/oauth2/authorize') self.assertEqual(qs, qs_expected) @mock_adfs("azure") def test_oauth_redir_azure_version_one(self): from django_auth_adfs.config import django_settings settings = deepcopy(django_settings) del settings.AUTH_ADFS["SERVER"] settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" with patch("django_auth_adfs.config.django_settings", settings), \ patch("django_auth_adfs.config.settings", Settings()), \ patch("django_auth_adfs.views.provider_config", ProviderConfig()): response = self.client.get("/oauth2/login?next=/test/") self.assertEqual(response.status_code, 302) redir = urlparse(response["Location"]) qs = parse_qs(redir.query) sq_expected = { 'scope': ['openid'], 'client_id': ['your-configured-client-id'], 'state': ['L3Rlc3Qv'], 'response_type': ['code'], 'resource': ['your-adfs-RPT-name'], 'redirect_uri': ['http://testserver/oauth2/callback'] } self.assertEqual(redir.scheme, 'https') self.assertEqual(redir.hostname, 'login.microsoftonline.com') self.assertEqual(redir.path.rstrip("/"), '/01234567-89ab-cdef-0123-456789abcdef/oauth2/authorize') self.assertEqual(qs, sq_expected) @mock_adfs("azure") def test_oauth_redir_azure_version_two(self): from django_auth_adfs.config import django_settings settings = deepcopy(django_settings) del settings.AUTH_ADFS["SERVER"] settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" settings.AUTH_ADFS["VERSION"] = 'v2.0' with patch("django_auth_adfs.config.django_settings", settings), \ patch("django_auth_adfs.config.settings", Settings()), \ patch("django_auth_adfs.views.provider_config", ProviderConfig()): response = self.client.get("/oauth2/login?next=/test/") self.assertEqual(response.status_code, 302) redir = urlparse(response["Location"]) qs = parse_qs(redir.query) sq_expected = { 'scope': ['openid api://your-adfs-RPT-name/.default'], 'client_id': ['your-configured-client-id'], 'state': ['L3Rlc3Qv'], 'response_type': ['code'], 'redirect_uri': ['http://testserver/oauth2/callback'] } self.assertEqual(redir.scheme, 'https') self.assertEqual(redir.hostname, 'login.microsoftonline.com') self.assertEqual(redir.path.rstrip("/"), '/01234567-89ab-cdef-0123-456789abcdef/oauth2/authorize') self.assertEqual(qs, sq_expected) @mock_adfs("azure") def test_scopes_generated_correctly(self): from django_auth_adfs.config import django_settings settings = deepcopy(django_settings) del settings.AUTH_ADFS["SERVER"] settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" settings.AUTH_ADFS["VERSION"] = 'v2.0' settings.AUTH_ADFS["SCOPES"] = ['openid', 'api://your-configured-client-id/user_impersonation'] with patch("django_auth_adfs.config.django_settings", settings), \ patch("django_auth_adfs.config.settings", Settings()), \ patch("django_auth_adfs.views.provider_config", ProviderConfig()): response = self.client.get("/oauth2/login?next=/test/") self.assertEqual(response.status_code, 302) redir = urlparse(response["Location"]) qs = parse_qs(redir.query) sq_expected = { 'scope': ['openid api://your-configured-client-id/user_impersonation'], 'client_id': ['your-configured-client-id'], 'state': ['L3Rlc3Qv'], 'response_type': ['code'], 'redirect_uri': ['http://testserver/oauth2/callback'] } self.assertEqual(redir.scheme, 'https') self.assertEqual(redir.hostname, 'login.microsoftonline.com') self.assertEqual(redir.path.rstrip("/"), '/01234567-89ab-cdef-0123-456789abcdef/oauth2/authorize') self.assertEqual(qs, sq_expected) @mock_adfs("2016") def test_inactive_user(self): user = User.objects.create(**{ User.USERNAME_FIELD: "testuser", "is_active": False }) response = self.client.get("/oauth2/callback", {'code': 'testcode'}) self.assertContains(response, "Your account is disabled", status_code=403) user.delete() @mock_adfs("2016") def test_nonexisting_user(self): from django_auth_adfs.config import django_settings settings = deepcopy(django_settings) settings.AUTH_ADFS["CREATE_NEW_USERS"] = False with patch("django_auth_adfs.config.django_settings", settings), \ patch("django_auth_adfs.backend.settings", Settings()): backend = AdfsAuthCodeBackend() self.assertRaises(PermissionDenied, backend.authenticate, self.request, authorization_code='testcode') ================================================ FILE: tests/test_drf_integration.py ================================================ import json from copy import deepcopy from django.test import RequestFactory, TestCase from mock import patch from rest_framework import exceptions from rest_framework.exceptions import AuthenticationFailed from django.contrib.auth.models import Group from django_auth_adfs.config import ProviderConfig, Settings from django_auth_adfs.rest_framework import AdfsAccessTokenAuthentication from .utils import build_access_token_adfs, build_access_token_azure, build_access_token_azure_guest, \ build_access_token_azure_guest_no_upn, build_access_token_azure_not_guest, \ build_access_token_azure_guest_with_idp, build_access_token_azure_groups_in_claim_source, \ mock_adfs class RestFrameworkIntegrationTests(TestCase): def setUp(self): self.drf_auth_class = AdfsAccessTokenAuthentication() adfs_response = build_access_token_adfs(RequestFactory().get('/'))[2] self.access_token_adfs = json.loads(adfs_response)['access_token'] azure_response = build_access_token_azure(RequestFactory().get('/'))[2] self.access_token_azure = json.loads(azure_response)['access_token'] azure_response_guest = build_access_token_azure_guest(RequestFactory().get('/'))[2] self.access_token_azure_guest = json.loads(azure_response_guest)['access_token'] azure_response_no_guest = build_access_token_azure_not_guest(RequestFactory().get('/'))[2] self.access_token_azure_no_guest = json.loads(azure_response_no_guest)['access_token'] azure_response_guest = build_access_token_azure_guest_no_upn(RequestFactory().get('/'))[2] self.access_token_azure_guest_no_upn = json.loads(azure_response_guest)['access_token'] azure_response_guest = build_access_token_azure_guest_with_idp(RequestFactory().get('/'))[2] self.access_token_azure_guest_with_idp = json.loads(azure_response_guest)['access_token'] azure_response = build_access_token_azure_groups_in_claim_source(RequestFactory().get('/'))[2] self.access_token_azure_groups_in_claim_source = json.loads(azure_response)['access_token'] Group.objects.create(name='group1') Group.objects.create(name='group2') Group.objects.create(name='group3') @mock_adfs("2012") def test_access_token_2012(self): access_token_header = "Bearer {}".format(self.access_token_adfs) request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) user, token = self.drf_auth_class.authenticate(request) self.assertEqual(user.username, "testuser") self.assertEqual(token, self.access_token_adfs.encode()) @mock_adfs("2016") def test_access_token_2016(self): access_token_header = "Bearer {}".format(self.access_token_adfs) request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) user, token = self.drf_auth_class.authenticate(request) self.assertEqual(user.username, "testuser") self.assertEqual(token, self.access_token_adfs.encode()) @mock_adfs("azure") def test_access_token_azure(self): access_token_header = "Bearer {}".format(self.access_token_azure) request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) from django_auth_adfs.config import django_settings settings = deepcopy(django_settings) del settings.AUTH_ADFS["SERVER"] settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" with patch("django_auth_adfs.config.django_settings", settings): with patch("django_auth_adfs.config.settings", Settings()): with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): user, token = self.drf_auth_class.authenticate(request) self.assertEqual(user.username, "testuser") @mock_adfs("azure") def test_access_token_azure_guest(self): access_token_header = "Bearer {}".format(self.access_token_azure_guest) request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) from django_auth_adfs.config import django_settings settings = deepcopy(django_settings) del settings.AUTH_ADFS["SERVER"] settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" settings.AUTH_ADFS["BLOCK_GUEST_USERS"] = True with patch("django_auth_adfs.config.django_settings", settings): with patch('django_auth_adfs.backend.settings', Settings()): with patch("django_auth_adfs.config.settings", Settings()): with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): with self.assertRaises(AuthenticationFailed): user, token = self.drf_auth_class.authenticate(request) @mock_adfs("azure") def test_access_token_azure_no_guest(self): access_token_header = "Bearer {}".format(self.access_token_azure_no_guest) request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) from django_auth_adfs.config import django_settings settings = deepcopy(django_settings) del settings.AUTH_ADFS["SERVER"] settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" settings.AUTH_ADFS["BLOCK_GUEST_USERS"] = True with patch("django_auth_adfs.config.django_settings", settings): with patch('django_auth_adfs.backend.settings', Settings()): with patch("django_auth_adfs.config.settings", Settings()): with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): user, token = self.drf_auth_class.authenticate(request) self.assertEqual(user.username, "testuser") @mock_adfs("azure") def test_access_token_azure_guest_but_no_upn(self): access_token_header = "Bearer {}".format(self.access_token_azure_guest_no_upn) request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) from django_auth_adfs.config import django_settings settings = deepcopy(django_settings) del settings.AUTH_ADFS["SERVER"] settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" settings.AUTH_ADFS["GUEST_USERNAME_CLAIM"] = "email" settings.AUTH_ADFS["BLOCK_GUEST_USERS"] = False with patch("django_auth_adfs.config.django_settings", settings): with patch('django_auth_adfs.backend.settings', Settings()): with patch("django_auth_adfs.config.settings", Settings()): with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): user, token = self.drf_auth_class.authenticate(request) self.assertEqual(user.username, "john.doe@example.com") @mock_adfs("azure") def test_access_token_azure_guest_with_idp(self): access_token_header = "Bearer {}".format(self.access_token_azure_guest_with_idp) request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) from django_auth_adfs.config import django_settings settings = deepcopy(django_settings) del settings.AUTH_ADFS["SERVER"] settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" settings.AUTH_ADFS["GUEST_USERNAME_CLAIM"] = "email" settings.AUTH_ADFS["BLOCK_GUEST_USERS"] = False with patch("django_auth_adfs.config.django_settings", settings): with patch('django_auth_adfs.backend.settings', Settings()): with patch("django_auth_adfs.config.settings", Settings()): with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): user, token = self.drf_auth_class.authenticate(request) self.assertEqual(user.username, "john.doe@example.com") @mock_adfs("azure") def test_access_token_azure_guest_but_no_upn_but_no_guest_username_claim(self): access_token_header = "Bearer {}".format(self.access_token_azure_guest_no_upn) request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) from django_auth_adfs.config import django_settings settings = deepcopy(django_settings) del settings.AUTH_ADFS["SERVER"] settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" settings.AUTH_ADFS["GUEST_USERNAME_CLAIM"] = None # <--- Set to None, should not be validated as OK settings.AUTH_ADFS["BLOCK_GUEST_USERS"] = False with patch("django_auth_adfs.config.django_settings", settings): with patch('django_auth_adfs.backend.settings', Settings()): with patch("django_auth_adfs.config.settings", Settings()): with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): with self.assertRaises(exceptions.AuthenticationFailed): self.drf_auth_class.authenticate(request) @mock_adfs("azure", requires_obo=True) def test_process_group_claim_from_ms_graph(self): access_token_header = "Bearer {}".format(self.access_token_azure_groups_in_claim_source) request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) from django_auth_adfs.config import django_settings settings = deepcopy(django_settings) del settings.AUTH_ADFS["SERVER"] settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" with patch("django_auth_adfs.config.django_settings", settings): with patch('django_auth_adfs.backend.settings', Settings()): with patch("django_auth_adfs.config.settings", Settings()): with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): user, _ = self.drf_auth_class.authenticate(request) self.assertEqual(user.username, "testuser") self.assertEqual(user.groups.all()[0].name, "group1") self.assertEqual(user.groups.all()[1].name, "group2") @mock_adfs("azure", requires_obo=True, mfa_error=True) def test_get_obo_access_token_mfa_error(self): access_token_header = "Bearer {}".format(self.access_token_azure_groups_in_claim_source) request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) from django_auth_adfs.config import django_settings settings = deepcopy(django_settings) del settings.AUTH_ADFS["SERVER"] settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" with patch("django_auth_adfs.config.django_settings", settings): with patch('django_auth_adfs.backend.settings', Settings()): with patch("django_auth_adfs.config.settings", Settings()): with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): with self.assertRaises(AuthenticationFailed): self.drf_auth_class.authenticate(request) @mock_adfs("azure", requires_obo=True, version='v2.0') def test_get_obo_access_token_version_2(self): access_token_header = "Bearer {}".format(self.access_token_azure_groups_in_claim_source) request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) from django_auth_adfs.config import django_settings settings = deepcopy(django_settings) del settings.AUTH_ADFS["SERVER"] settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" settings.AUTH_ADFS["VERSION"] = 'v2.0' with patch("django_auth_adfs.config.django_settings", settings): with patch('django_auth_adfs.backend.settings', Settings()): with patch("django_auth_adfs.config.settings", Settings()): with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): user, _ = self.drf_auth_class.authenticate(request) self.assertEqual(user.username, "testuser") self.assertEqual(user.groups.all()[0].name, "group1") self.assertEqual(user.groups.all()[1].name, "group2") @mock_adfs("azure", requires_obo=True, missing_graph_group_perm=True) def test_missing_ms_graph_group_permission(self): access_token_header = "Bearer {}".format(self.access_token_azure_groups_in_claim_source) request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) from django_auth_adfs.config import django_settings settings = deepcopy(django_settings) del settings.AUTH_ADFS["SERVER"] settings.AUTH_ADFS["TENANT_ID"] = "dummy_tenant_id" with patch("django_auth_adfs.config.django_settings", settings): with patch('django_auth_adfs.backend.settings', Settings()): with patch("django_auth_adfs.config.settings", Settings()): with patch("django_auth_adfs.backend.provider_config", ProviderConfig()): with self.assertRaises(AuthenticationFailed): self.drf_auth_class.authenticate(request) @mock_adfs("2012") def test_access_token_exceptions(self): access_token_header = "Bearer non-existing-token" request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) with self.assertRaises(exceptions.AuthenticationFailed): self.drf_auth_class.authenticate(request) # use the azure token on adfs should not work access_token_header = "Bearer {}".format(self.access_token_azure) request = RequestFactory().get('/api', HTTP_AUTHORIZATION=access_token_header) with self.assertRaises(exceptions.AuthenticationFailed): self.drf_auth_class.authenticate(request) ================================================ FILE: tests/test_settings.py ================================================ import sys from copy import deepcopy from django.core.exceptions import ImproperlyConfigured from django.test import TestCase, SimpleTestCase, override_settings from mock import patch from django_auth_adfs.config import django_settings from django_auth_adfs.config import Settings from django_auth_adfs.config import ProviderConfig from .custom_config import Settings as CustomSettings class SettingsTests(TestCase): def test_no_settings(self): settings = deepcopy(django_settings) del settings.AUTH_ADFS with patch("django_auth_adfs.config.django_settings", settings): with self.assertRaises(ImproperlyConfigured): Settings() def test_claim_mapping_overlapping_username_field(self): settings = deepcopy(django_settings) settings.AUTH_ADFS["CLAIM_MAPPING"] = {"username": "samaccountname"} with patch("django_auth_adfs.config.django_settings", settings): with self.assertRaises(ImproperlyConfigured): Settings() def test_tenant_and_server(self): settings = deepcopy(django_settings) settings.AUTH_ADFS["TENANT_ID"] = "abc" settings.AUTH_ADFS["SERVER"] = "abc" with patch("django_auth_adfs.config.django_settings", settings): with self.assertRaises(ImproperlyConfigured): Settings() def test_no_tenant_but_block_guest(self): settings = deepcopy(django_settings) settings.AUTH_ADFS["SERVER"] = "abc" settings.AUTH_ADFS["BLOCK_GUEST_USERS"] = True with patch("django_auth_adfs.config.django_settings", settings): with self.assertRaises(ImproperlyConfigured): Settings() def test_tenant_with_block_users(self): settings = deepcopy(django_settings) del settings.AUTH_ADFS["SERVER"] settings.AUTH_ADFS["TENANT_ID"] = "abc" settings.AUTH_ADFS["BLOCK_GUEST_USERS"] = True with patch("django_auth_adfs.config.django_settings", settings): current_settings = Settings() self.assertTrue(current_settings.BLOCK_GUEST_USERS) def test_unknown_setting(self): settings = deepcopy(django_settings) settings.AUTH_ADFS["dummy"] = "abc" with patch("django_auth_adfs.config.django_settings", settings): with self.assertRaises(ImproperlyConfigured): Settings() def test_required_setting(self): settings = deepcopy(django_settings) del settings.AUTH_ADFS["AUDIENCE"] with patch("django_auth_adfs.config.django_settings", settings): with self.assertRaises(ImproperlyConfigured): Settings() def test_default_failed_response_setting(self): settings = deepcopy(django_settings) with patch("django_auth_adfs.config.django_settings", settings): s = Settings() self.assertTrue(callable(s.CUSTOM_FAILED_RESPONSE_VIEW)) def test_dotted_path_failed_response_setting(self): settings = deepcopy(django_settings) settings.AUTH_ADFS["CUSTOM_FAILED_RESPONSE_VIEW"] = 'tests.views.test_failed_response' with patch("django_auth_adfs.config.django_settings", settings): s = Settings() self.assertTrue(callable(s.CUSTOM_FAILED_RESPONSE_VIEW)) def test_settings_version(self): settings = deepcopy(django_settings) current_settings = Settings() self.assertEqual(current_settings.VERSION, "v1.0") settings.AUTH_ADFS["TENANT_ID"] = "abc" del settings.AUTH_ADFS["SERVER"] settings.AUTH_ADFS["VERSION"] = "v2.0" with patch("django_auth_adfs.config.django_settings", settings): current_settings = Settings() self.assertEqual(current_settings.VERSION, "v2.0") def test_not_azure_but_version_is_set(self): settings = deepcopy(django_settings) settings.AUTH_ADFS["SERVER"] = "abc" settings.AUTH_ADFS["VERSION"] = "v2.0" with patch("django_auth_adfs.config.django_settings", settings): with self.assertRaises(ImproperlyConfigured): Settings() def test_configured_proxy(self): settings = Settings() settings.PROXIES = {'http': '10.0.0.1'} with patch("django_auth_adfs.config.settings", settings): provider_config = ProviderConfig() self.assertEqual(provider_config.session.proxies, {'http': '10.0.0.1'}) def test_no_configured_proxy(self): provider_config = ProviderConfig() self.assertIsNone(provider_config.session.proxies) class CustomSettingsTests(SimpleTestCase): def setUp(self): sys.modules.pop('django_auth_adfs.config', None) def tearDown(self): sys.modules.pop('django_auth_adfs.config', None) def test_dotted_path(self): auth_adfs = deepcopy(django_settings).AUTH_ADFS auth_adfs['SETTINGS_CLASS'] = 'tests.custom_config.Settings' with override_settings(AUTH_ADFS=auth_adfs): from django_auth_adfs.config import settings self.assertIsInstance(settings, CustomSettings) ================================================ FILE: tests/urls.py ================================================ from django.urls import include, re_path urlpatterns = [ re_path(r'^oauth2/', include('django_auth_adfs.urls')), re_path(r'^oauth2/', include('django_auth_adfs.drf_urls')), ] ================================================ FILE: tests/utils.py ================================================ import base64 import json import os import re import time from datetime import datetime, tzinfo, timedelta from functools import partial import jwt import responses from cryptography import x509 from cryptography.hazmat.backends import default_backend as crypto_default_backend from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import serialization as crypto_serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.x509.oid import NameOID def generate_key_and_cert(): signing_key = rsa.generate_private_key( backend=crypto_default_backend(), public_exponent=65537, key_size=2048 ) subject = issuer = x509.Name([ x509.NameAttribute(NameOID.COUNTRY_NAME, u"US"), x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, u"CA"), x509.NameAttribute(NameOID.LOCALITY_NAME, u"San Francisco"), x509.NameAttribute(NameOID.ORGANIZATION_NAME, u"My Company"), x509.NameAttribute(NameOID.COMMON_NAME, u"example.com"), ]) signing_cert = x509.CertificateBuilder().subject_name( subject ).issuer_name( issuer ).public_key( signing_key.public_key() ).serial_number( x509.random_serial_number() ).not_valid_before( datetime.utcnow() ).not_valid_after( # Our certificate will be valid for 10 days datetime.utcnow() + timedelta(days=10) # Sign our certificate with our private key ).sign( signing_key, hashes.SHA256(), crypto_default_backend() ).public_bytes(crypto_serialization.Encoding.DER) return signing_key, signing_cert class SimpleUtc(tzinfo): def tzname(self, dt): return "UTC" def utcoffset(self, dt): return timedelta(0) def load_json(file): with open(os.path.join(os.path.dirname(__file__), file), mode="r") as f: data = json.load(f) return data def build_access_token_adfs(request): issuer = "http://adfs.example.com/adfs/services/trust" return do_build_access_token(request, issuer) def build_access_token_azure(request): issuer = "https://sts.windows.net/01234567-89ab-cdef-0123-456789abcdef/" return do_build_access_token(request, issuer) def build_access_token_azure_not_guest(request): issuer = "https://sts.windows.net/01234567-89ab-cdef-0123-456789abcdef/" return do_build_access_token(request, issuer, schema='dummy_tenant_id') def build_access_token_azure_guest(request): issuer = "https://sts.windows.net/01234567-89ab-cdef-0123-456789abcdef/" return do_build_access_token(request, issuer, schema='guest_tenant_id') def build_access_token_azure_guest_no_upn(request): issuer = "https://sts.windows.net/01234567-89ab-cdef-0123-456789abcdef/" return do_build_access_token(request, issuer, schema='guest_tenant_id', no_upn=True) def build_access_token_azure_guest_with_idp(request): issuer = "https://sts.windows.net/01234567-89ab-cdef-0123-456789abcdef/" return do_build_access_token(request, issuer, schema='dummy_tenant_id', no_upn=True, idp="guest_idp") def build_access_token_azure_groups_in_claim_source(request): issuer = "https://sts.windows.net/01234567-89ab-cdef-0123-456789abcdef/" return do_build_access_token(request, issuer, groups_in_claim_names=True) def do_build_mfa_error(request): response = {'error_description': 'AADSTS50076'} return 400, [], json.dumps(response) def do_build_graph_response(request): return do_build_ms_graph_groups(request) def do_build_graph_response_no_group_perm(request): return do_build_ms_graph_groups(request, missing_group_names=True) def do_build_access_token(request, issuer, schema=None, no_upn=False, idp=None, groups_in_claim_names=False): issued_at = int(time.time()) expires = issued_at + 3600 auth_time = datetime.utcnow() auth_time = auth_time.replace(tzinfo=SimpleUtc(), microsecond=0) claims = { "aud": "microsoft:identityserver:your-RelyingPartyTrust-identifier", "iss": issuer, "idp": idp or issuer, "iat": issued_at, "exp": expires, "winaccountname": "testuser", "group": ["group1", "group2"], "given_name": "John", "family_name": "Doe", "email": "john.doe@example.com", "sub": "john.doe@example.com", "custom_employee_id": 182, "user_is_staff": "True", "user_is_superuser": "yes", "appid": "your-configured-client-id", "auth_time": auth_time.isoformat(), "authmethod": "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", "ver": "1.0" } if schema: claims['tid'] = schema if issuer.startswith('https://sts.windows.net'): claims['upn'] = 'testuser' claims['groups'] = claims['group'] if no_upn: del claims['upn'] if groups_in_claim_names: if 'groups' in claims: del claims['groups'] del claims['group'] claims['_claim_names'] = { "groups": "src1", } claims['_claim_sources'] = { "src1": { "endpoint": ( "https://graph.windows.net/01234567-89ab-cdef-0123-456789abcdef" "/users/23456789-01bc-defg-1234-56789bcdefg/getMemberObjects" ), } } token = jwt.encode(claims, signing_key_b, algorithm="RS256") response = { 'resource': 'django_website.adfs.relying_party_id', 'token_type': 'bearer', 'refresh_token_expires_in': 28799, 'refresh_token': 'random_refresh_token', 'expires_in': 3600, 'id_token': 'not_used', 'access_token': token.decode() if isinstance(token, bytes) else token # PyJWT>=2 returns a str instead of bytes } return 200, [], json.dumps(response) def do_build_obo_access_token(request): obo_token = { "aud": "https://graph.microsoft.com", "iss": "https://sts.windows.net/01234567-89ab-cdef-0123-456789abcdef/", "iat": 1660851337, "nbf": 1660851337, "exp": 1660856510, "acct": 0, "acr": "1", "aio": ( "AUQAu/8TBCDAcvfLrjwjR53Uci8V5KCONDvJXGEFM/gMeVSp6/LV338RTspRjxIhbmNLcAGa80KVXXglM7+ea1uqRKkRNCa9bQ==" ), "amr": [ "wia", "mfa" ], "app_displayname": "AppName", "appid": "2345a5bc-123a-0a1b-0a12-a12345b6cd7e", "appidacr": "1", "family_name": "Doe", "given_name": "John", "idtyp": "user", "ipaddr": "1.2.3.4", "name": "Doe, John (Expert)", "oid": "2345a5bc-123a-0a1b-0a12-a12345b6cd7e", "onprem_sid": "S-1-5-21-456123456-1364589140-123456543-563809", "platf": "5", "puid": "10030000AD9D1530", "rh": "0.AS8A1AA4aCjPK0uCpKTt25xSNwMAAAAAAAAAwAAAAAAAAAAvAEQ.", "scp": "email GroupMember.Read.All openid profile User.Read", "signin_state": [ "inknownntwk" ], "sub": "PZBipRglYn2dgemAP_qDM3QzF1nosfdylWx8hsEwzYA", "tenant_region_scope": "EU", "tid": "01234567-89ab-cdef-0123-456789abcdef", "unique_name": "john.doe@example.com", "upn": "john.doe@example.com", "uti": "D8NUc9MAwkutG-iBUnsBAA", "ver": "1.0", "wids": [ "2345a5bc-123a-0a1b-0a12-a12345b6cd7e", ], "xms_tcdt": 1467198948 } token = jwt.encode(obo_token, signing_key_b, algorithm="RS256") response = { 'token_type': 'bearer', 'scope': 'email GroupMember.Read.All openid profile User.Read', 'expires_in': '4872', 'ext_expires_in': '4872', 'expires_on': '1660856510', 'not_before': '1660851337', 'resource': 'https://graph.microsoft.com', 'refresh_token': 'not_used', 'access_token': token.decode() if isinstance(token, bytes) else token # PyJWT>=2 returns a str instead of bytes } return 200, [], json.dumps(response) def do_build_ms_graph_groups(request, missing_group_names=False): response = { "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#groups", "value": [ { "id": "12ab345c-6abc-427f-85ca-93fc0cc7f00d", "deletedDateTime": None, "classification": None, "createdDateTime": "2020-11-02T13:06:02Z", "creationOptions": [], "description": None, "displayName": "group1", "expirationDateTime": None, "groupTypes": [], "isAssignableToRole": None, "mail": None, "mailEnabled": False, "mailNickname": "group1", "membershipRule": None, "membershipRuleProcessingState": None, "onPremisesDomainName": "example.com", "onPremisesLastSyncDateTime": "2022-08-18T19:32:43Z", "onPremisesNetBiosName": "COMPANY", "onPremisesSamAccountName": "group1", "onPremisesSecurityIdentifier": "S-1-5-21-1234567891-1234567891-1234567891-123456", "onPremisesSyncEnabled": True, "preferredDataLocation": None, "preferredLanguage": None, "proxyAddresses": [], "renewedDateTime": "2020-11-02T13:06:02Z", "resourceBehaviorOptions": [], "resourceProvisioningOptions": [], "securityEnabled": False, "securityIdentifier": "S-1-12-1-1234567891-1234567891-1234567891-1234567891", "theme": None, "visibility": None, "onPremisesProvisioningErrors": [] }, { "id": "23ab456c-7abc-427f-85ca-93fc0cc7f00d", "deletedDateTime": None, "classification": None, "createdDateTime": "2020-11-02T13:06:02Z", "creationOptions": [], "description": None, "displayName": "group2", "expirationDateTime": None, "groupTypes": [], "isAssignableToRole": None, "mail": None, "mailEnabled": False, "mailNickname": "group2", "membershipRule": None, "membershipRuleProcessingState": None, "onPremisesDomainName": "example.com", "onPremisesLastSyncDateTime": "2022-08-18T19:32:43Z", "onPremisesNetBiosName": "COMPANY", "onPremisesSamAccountName": "group2", "onPremisesSecurityIdentifier": "S-1-5-21-1234567891-1234567891-1234567891-123456", "onPremisesSyncEnabled": True, "preferredDataLocation": None, "preferredLanguage": None, "proxyAddresses": [], "renewedDateTime": "2020-11-02T13:06:02Z", "resourceBehaviorOptions": [], "resourceProvisioningOptions": [], "securityEnabled": False, "securityIdentifier": "S-1-12-1-1234567891-1234567891-1234567891-1234567891", "theme": None, "visibility": None, "onPremisesProvisioningErrors": [] }, ] } if missing_group_names: for group in response["value"]: group["displayName"] = None return 200, [], json.dumps(response) def build_openid_keys(request, empty_keys=False): if empty_keys: keys = {"keys": []} else: keys = { "keys": [ { "kty": "RSA", "use": "sig", "kid": "dummythumbprint", "x5t": "dummythumbprint", "n": "somebase64encodedmodulus", "e": "somebase64encodedexponent", "x5c": [base64.b64encode(signing_cert_a).decode(), ] }, { "kty": "RSA", "use": "sig", "kid": "dummythumbprint", "x5t": "dummythumbprint", "n": "somebase64encodedmodulus", "e": "somebase64encodedexponent", "x5c": [base64.b64encode(signing_cert_b).decode(), ] }, ] } return 200, [], json.dumps(keys) def build_adfs_meta(request): with open(os.path.join(os.path.dirname(__file__), "mock_files/FederationMetadata.xml"), mode="r") as f: data = "".join(f.readlines()) data = data.replace("REPLACE_WITH_CERT_A", base64.b64encode(signing_cert_a).decode()) data = data.replace("REPLACE_WITH_CERT_B", base64.b64encode(signing_cert_b).decode()) return 200, [], data def mock_adfs( adfs_version, empty_keys=False, mfa_error=False, guest=False, version=None, requires_obo=False, missing_graph_group_perm=False, ): if adfs_version not in ["2012", "2016", "azure"]: raise NotImplementedError("This version of ADFS is not implemented") def do_mock(test_func): def wrapper(*original_args, **original_kwargs): prefix_table = { "2012": "https://adfs.example.com", "2016": "https://adfs.example.com", "azure": "https://login.microsoftonline.com", } prefix = prefix_table[adfs_version] ms_graph_endpoint = "https://graph.microsoft.com/" if version == "v2.0": openid_cfg = re.compile(prefix + r".*{}/\.well-known/openid-configuration".format(version)) token_endpoint = re.compile(prefix + r".*/oauth2/{}/token".format(version)) else: openid_cfg = re.compile(prefix + r".*\.well-known/openid-configuration") token_endpoint = re.compile(prefix + r".*/oauth2/token") openid_keys = re.compile(prefix + r".*/discovery/keys") adfs_meta = re.compile(prefix + r".*/FederationMetadata/2007-06/FederationMetadata\.xml") ms_graph_groups = re.compile(ms_graph_endpoint + r".*/transitiveMemberOf/microsoft.graph.group") with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: # https://github.com/getsentry/responses if adfs_version == "2016": rsps.add( rsps.GET, openid_cfg, json=load_json("mock_files/adfs-openid-configuration.json") ) rsps.add_callback( rsps.GET, openid_keys, callback=partial(build_openid_keys, empty_keys=empty_keys), content_type='application/json', ) elif adfs_version == "azure": if version == "v2.0": rsps.add( rsps.GET, openid_cfg, json=load_json("mock_files/azure-openid-configuration-v2.json") ) else: rsps.add( rsps.GET, openid_cfg, json=load_json("mock_files/azure-openid-configuration.json") ) rsps.add_callback( rsps.GET, openid_keys, callback=partial(build_openid_keys, empty_keys=empty_keys), content_type='application/json', ) else: rsps.add( rsps.GET, openid_cfg, status=404 ) rsps.add( rsps.GET, openid_keys, status=404 ) rsps.add_callback( rsps.GET, adfs_meta, callback=build_adfs_meta, content_type='application/xml', ) if adfs_version == "azure": if guest: rsps.add_callback( rsps.POST, token_endpoint, callback=build_access_token_azure_guest, content_type='application/json', ) rsps.add_callback( rsps.POST, token_endpoint, callback=build_access_token_azure, content_type='application/json', ) if requires_obo: if mfa_error: rsps.add_callback( rsps.GET, token_endpoint, callback=do_build_mfa_error, content_type='application/json', ) else: rsps.add_callback( rsps.GET, token_endpoint, callback=do_build_obo_access_token, content_type='application/json' ) if missing_graph_group_perm: rsps.add_callback( rsps.GET, ms_graph_groups, callback=do_build_graph_response_no_group_perm, content_type='application/json', ) else: rsps.add_callback( rsps.GET, ms_graph_groups, callback=do_build_graph_response, content_type='application/json', ) else: if mfa_error: rsps.add_callback( rsps.POST, token_endpoint, callback=do_build_mfa_error, content_type='application/json', ) else: rsps.add_callback( rsps.POST, token_endpoint, callback=build_access_token_adfs, content_type='application/json', ) test_func(*original_args, **original_kwargs) return wrapper return do_mock signing_key_a, signing_cert_a = generate_key_and_cert() signing_key_b, signing_cert_b = generate_key_and_cert() ================================================ FILE: tests/views.py ================================================ def test_failed_response(request, error_message, status): pass ================================================ FILE: vagrant/01-setup-domain.ps1 ================================================ # ########## SETTINGS ######### $domainName = "example.com" $netbiosName = "EXAMPLE" $safeModePwd = "Password123" # ############################# Set-LocalUser ` -name "administrator" ` -AccountNeverExpires ` -Password (Convertto-SecureString -AsPlainText "Vagrant123" -Force) ` -PasswordNeverExpires $true # Install and configure domain controller role # -------------------------------------------- Write-Host "Installing domain features..." Install-WindowsFeature -Name AD-Domain-Services -IncludeManagementTools Write-Host "Promoting DC..." Import-Module ADDSDeployment Install-ADDSForest ` -CreateDnsDelegation:$false ` -DatabasePath "C:\Windows\NTDS" ` -DomainMode "WinThreshold" ` -DomainName $domainName ` -DomainNetbiosName $netbiosName ` -ForestMode "WinThreshold" ` -InstallDns:$true ` -LogPath "C:\Windows\NTDS" ` -SysvolPath "C:\Windows\SYSVOL" ` -Force:$true ` -SafeModeAdministratorPassword (Convertto-SecureString -AsPlainText $safeModePwd -Force) ` -NoRebootOnCompletion ================================================ FILE: vagrant/02-setup-vagrant-user.ps1 ================================================ Write-Host "Waiting for domain controller to become reachable." $isUp = $false while($isUp -eq $false) { Try { $domain = Get-ADDomain $isUp = $true } Catch [Microsoft.ActiveDirectory.Management.ADServerDownException] { Write-Host "Retrying in 30 seconds" $isUp = $false Start-Sleep 30 } } Add-ADGroupMember -Identity "Domain Admins" -Members vagrant Add-ADGroupMember -Identity "Enterprise Admins" -Members vagrant Add-ADGroupMember -Identity "Schema Admins" -Members vagrant ================================================ FILE: vagrant/03-setup-adfs.ps1 ================================================ # ########## SETTINGS ######### $adfsHost = "adfs" # ############################# Write-Host "Waiting for domain controller to become reachable." $isUp = $false while($isUp -eq $false) { Try { $domain = Get-ADDomain $isUp = $true } Catch [Microsoft.ActiveDirectory.Management.ADServerDownException] { Write-Host "Retrying in 30 seconds" $isUp = $false Start-Sleep 30 } } # Install the ADFS role # --------------------- Write-Host "Installing ADFS role..." Install-WindowsFeature -Name ADFS-Federation -IncludeManagementTools # Add ADFS DNS record # ------------------- Write-Host "Adding DNS record..." $ip = Get-NetIPAddress -InterfaceAlias "Ethernet 2" -AddressFamily ipv4 Add-DnsServerResourceRecordA -Name $adfsHost -IPv4Address $ip.IPAddress -ZoneName (Get-ADDomain).Forest # Generate ADFS certificate # ------------------------- Write-Host "Generating self signed certificate for ADFS..." Import-Module \\vboxsrv\vagrant\vagrant\New-SelfSignedCertificateEx.ps1 $cert = New-SelfSignedCertificateEx ` -Subject ("CN="+$adfsHost+"."+(Get-ADDomain).Forest) ` -SubjectAlternativeName ($adfsHost+"."+(Get-ADDomain).Forest) ` -AlgorithmName RSA ` -KeyLength 2048 ` -SignatureAlgorithm SHA256 ` -StoreLocation LocalMachine # Configure ADFS # -------------- Write-Host "Configure ADFS..." # Needed to be able to create a group Managed Service Account # set-service kdssvc -StartupType Automatic Add-KdsRootKey -EffectiveTime (Get-Date).AddHours(-10) Write-Host "Creating Group Managed Service Account..." $Name = 'FsGmsa' $DNS_Name = $adfsHost+"."+(Get-ADDomain).Forest New-ADServiceAccount -Name $Name -DNSHostName $DNS_Name -PrincipalsAllowedToRetrieveManagedPassword "$env:computername`$" Import-Module ADFS Install-AdfsFarm ` -CertificateThumbprint $cert.Thumbprint ` -FederationServiceDisplayName "Example Corp" ` -FederationServiceName ($adfsHost+"."+(Get-ADDomain).Forest) ` -GroupServiceAccountIdentifier ((Get-ADDomain).NetBIOSName + "\FsGmsa`$") ` -OverwriteConfiguration # 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 sc.exe triggerinfo kdssvc start/networkon ================================================ FILE: vagrant/04-example-adfs-config.ps1 ================================================ # ########## SETTINGS ######### $webIP = "10.10.10.10" $webName = "web" $appName = "Django Application" $clientId = "487d8ff7-80a8-4f62-b926-c2852ab06e94" $relyingPartyId = "web.example.com" # ############################# Write-Host "Waiting for domain controller to become reachable." $isUp = $false while($isUp -eq $false) { Try { $domain = Get-ADDomain $isUp = $true } Catch { Write-Host "Retrying in 15 seconds" $isUp = $false Start-Sleep 15 } } # Add webserver DNS record # ------------------------ Write-Host "Adding DNS record..." Add-DnsServerResourceRecordA -Name $webName -IPv4Address $webIP -ZoneName (Get-ADDomain).Forest # Add example users and groups # ---------------------------- Write-Host "Creating Django Admins group" $staffGroup = New-ADGroup ` -Name "Django Admins" ` -SamAccountName django_admins ` -GroupCategory Security ` -GroupScope Global ` -Passthru Write-Host "Creating user Alice..." New-ADUser ` -Name "Alice" ` -GivenName Alice ` -SurName Wonder ` -SamAccountName alice ` -EmailAddress ("alice@"+(Get-ADDomain).Forest) ` -UserPrincipalName ("alice@"+(Get-ADDomain).Forest) ` -AccountPassword (convertto-securestring "Password123" -asplaintext -force) ` -Enabled $true Write-Host "Creating user Bob..." $bob = New-ADUser ` -Name "Bob" ` -GivenName Bob ` -SurName Builder ` -SamAccountName bob ` -EmailAddress ("bob@"+(Get-ADDomain).Forest) ` -UserPrincipalName ("bob@"+(Get-ADDomain).Forest) ` -AccountPassword (convertto-securestring "Password123" -asplaintext -force) ` -Enabled $true ` -Passthru Add-ADGroupMember -Identity django_admins -Members $bob Write-Host "Disabling Internet Explorer Enhanced Security Configuration" $AdminKey = "HKLM:\SOFTWARE\Microsoft\Active Setup\Installed Components\{A509B1A7-37EF-4b3f-8CFC-4F3A74704073}" $UserKey = "HKLM:\SOFTWARE\Microsoft\Active Setup\Installed Components\{A509B1A8-37EF-4b3f-8CFC-4F3A74704073}" Set-ItemProperty -Path $AdminKey -Name "IsInstalled" -Value 0 Set-ItemProperty -Path $UserKey -Name "IsInstalled" -Value 0 # Add ADFS config # --------------- Write-Host "Waiting for Federation Server to become reachable." $isUp = $false while($isUp -eq $false) { Try { $domain = Get-AdfsProperties $isUp = $true } Catch { Write-Host "Retrying in 15 seconds" $isUp = $false Start-Sleep 15 } } Write-Host "Adding application group $appName" New-AdfsApplicationGroup -Name $appName -ApplicationGroupIdentifier $appName Write-Host "Adding native application" Add-AdfsNativeClientApplication ` -name "$appName - Native application" ` -Identifier $clientId ` -ApplicationGroupIdentifier $appName ` -RedirectUri ("http://$webName."+(Get-ADDomain).Forest+":8000/oauth2/callback") Write-Host "Adding web application" Add-AdfsWebApiApplication ` -Name "$appName - Web application" ` -Identifier $relyingPartyId ` -AccessControlPolicyName "Permit everyone" ` -ApplicationGroupIdentifier $appName ` -IssuanceTransformRules ( '@RuleTemplate = "LdapClaims" @RuleName = "User attribute claims" c:[ Type == "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname", Issuer == "AD AUTHORITY" ] => issue( store = "Active Directory", types = ( "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname", "http://schemas.xmlsoap.org/claims/Group", "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname" ), query = ";mail,givenName,sn,tokenGroups,sAMAccountName;{0}", param = c.Value );' ) Write-Host "Adding native application" Grant-AdfsApplicationPermission ` -ClientRoleIdentifier $clientId ` -ServerRoleIdentifier $relyingPartyId ` -ScopeNames "openid" ================================================ FILE: vagrant/New-SelfSignedCertificateEx.ps1 ================================================ ##################################################################### # New-SelfSignedCertificateEx.ps1 # Version 1.2 # # Creates self-signed certificate. This tool is a base replacement # for deprecated makecert.exe # # Vadims Podans (c) 2013 - 2016 # http://en-us.sysadmins.lv/ ##################################################################### #requires -Version 2.0 function New-SelfSignedCertificateEx { <# .Synopsis This cmdlet generates a self-signed certificate. .Description This cmdlet generates a self-signed certificate with the required data. .Parameter Subject Specifies the certificate subject in a X500 distinguished name format. Example: CN=Test Cert, OU=Sandbox .Parameter NotBefore Specifies the date and time when the certificate become valid. By default previous day date is used. .Parameter NotAfter Specifies the date and time when the certificate expires. By default, the certificate is valid for 1 year. .Parameter SerialNumber Specifies the desired serial number in a hex format. Example: 01a4ff2 .Parameter ProviderName Specifies the Cryptography Service Provider (CSP) name. You can use either legacy CSP and Key Storage Providers (KSP). By default "Microsoft Enhanced Cryptographic Provider v1.0" CSP is used. .Parameter AlgorithmName Specifies the public key algorithm. By default RSA algorithm is used. RSA is the only algorithm supported by legacy CSPs. With key storage providers (KSP) you can use CNG algorithms, like ECDH. For CNG algorithms you must use full name: ECDH_P256 ECDH_P384 ECDH_P521 In addition, KeyLength parameter must be specified explicitly when non-RSA algorithm is used. .Parameter KeyLength Specifies the key length to generate. By default 2048-bit key is generated. .Parameter KeySpec Specifies the public key operations type. The possible values are: Exchange and Signature. Default value is Exchange. .Parameter EnhancedKeyUsage Specifies the intended uses of the public key contained in a certificate. You can specify either, EKU friendly name (for example 'Server Authentication') or object identifier (OID) value (for example '1.3.6.1.5.5.7.3.1'). .Parameter KeyUsages Specifies restrictions on the operations that can be performed by the public key contained in the certificate. Possible values (and their respective integer values to make bitwise operations) are: EncipherOnly CrlSign KeyCertSign KeyAgreement DataEncipherment KeyEncipherment NonRepudiation DigitalSignature DecipherOnly you can combine key usages values by using bitwise OR operation. when combining multiple flags, they must be enclosed in quotes and separated by a comma character. For example, to combine KeyEncipherment and DigitalSignature flags you should type: "KeyEncipherment, DigitalSignature". If the certificate is CA certificate (see IsCA parameter), key usages extension is generated automatically with the following key usages: Certificate Signing, Off-line CRL Signing, CRL Signing. .Parameter SubjectAlternativeName Specifies alternative names for the subject. Unlike Subject field, this extension allows to specify more than one name. Also, multiple types of alternative names are supported. The cmdlet supports the following SAN types: RFC822 Name IP address (both, IPv4 and IPv6) Guid Directory name DNS name .Parameter IsCA Specifies whether the certificate is CA (IsCA = $true) or end entity (IsCA = $false) certificate. If this parameter is set to $false, PathLength parameter is ignored. Basic Constraints extension is marked as critical. .PathLength Specifies the number of additional CA certificates in the chain under this certificate. If PathLength parameter is set to zero, then no additional (subordinate) CA certificates are permitted under this CA. .CustomExtension Specifies the custom extension to include to a self-signed certificate. This parameter must not be used to specify the extension that is supported via other parameters. In order to use this parameter, the extension must be formed in a collection of initialized System.Security.Cryptography.X509Certificates.X509Extension objects. .Parameter SignatureAlgorithm Specifies signature algorithm used to sign the certificate. By default 'SHA1' algorithm is used. .Parameter FriendlyName Specifies friendly name for the certificate. .Parameter StoreLocation Specifies the store location to store self-signed certificate. Possible values are: 'CurrentUser' and 'LocalMachine'. 'CurrentUser' store is intended for user certificates and computer (as well as CA) certificates must be stored in 'LocalMachine' store. .Parameter StoreName Specifies the container name in the certificate store. Possible container names are: AddressBook AuthRoot CertificateAuthority Disallowed My Root TrustedPeople TrustedPublisher .Parameter Path Specifies the path to a PFX file to export a self-signed certificate. .Parameter Password Specifies the password for PFX file. .Parameter AllowSMIME Enables Secure/Multipurpose Internet Mail Extensions for the certificate. .Parameter Exportable Marks private key as exportable. Smart card providers usually do not allow exportable keys. .Example New-SelfsignedCertificateEx -Subject "CN=Test Code Signing" -EKU "Code Signing" -KeySpec "Signature" ` -KeyUsage "DigitalSignature" -FriendlyName "Test code signing" -NotAfter $([datetime]::now.AddYears(5)) Creates a self-signed certificate intended for code signing and which is valid for 5 years. Certificate is saved in the Personal store of the current user account. .Example New-SelfsignedCertificateEx -Subject "CN=www.domain.com" -EKU "Server Authentication", "Client authentication" ` -KeyUsage "KeyEcipherment, DigitalSignature" -SAN "sub.domain.com","www.domain.com","192.168.1.1" ` -AllowSMIME -Path C:\test\ssl.pfx -Password (ConvertTo-SecureString "P@ssw0rd" -AsPlainText -Force) -Exportable ` -StoreLocation "LocalMachine" Creates a self-signed SSL certificate with multiple subject names and saves it to a file. Additionally, the certificate is saved in the Personal store of the Local Machine store. Private key is marked as exportable, so you can export the certificate with a associated private key to a file at any time. The certificate includes SMIME capabilities. .Example New-SelfsignedCertificateEx -Subject "CN=www.domain.com" -EKU "Server Authentication", "Client authentication" ` -KeyUsage "KeyEcipherment, DigitalSignature" -SAN "sub.domain.com","www.domain.com","192.168.1.1" ` -StoreLocation "LocalMachine" -ProviderName "Microsoft Software Key Storae Provider" -AlgorithmName ecdh_256 ` -KeyLength 256 -SignatureAlgorithm sha256 Creates a self-signed SSL certificate with multiple subject names and saves it to a file. Additionally, the certificate is saved in the Personal store of the Local Machine store. Private key is marked as exportable, so you can export the certificate with a associated private key to a file at any time. Certificate uses Ellyptic Curve Cryptography (ECC) key algorithm ECDH with 256-bit key. The certificate is signed by using SHA256 algorithm. .Example New-SelfsignedCertificateEx -Subject "CN=Test Root CA, OU=Sandbox" -IsCA $true -ProviderName ` "Microsoft Software Key Storage Provider" -Exportable Creates self-signed root CA certificate. #> [OutputType('[System.Security.Cryptography.X509Certificates.X509Certificate2]')] [CmdletBinding(DefaultParameterSetName = '__store')] param ( [Parameter(Mandatory = $true, Position = 0)] [string]$Subject, [Parameter(Position = 1)] [datetime]$NotBefore = [DateTime]::Now.AddDays(-1), [Parameter(Position = 2)] [datetime]$NotAfter = $NotBefore.AddDays(365), [string]$SerialNumber, [Alias('CSP')] [string]$ProviderName = "Microsoft Enhanced Cryptographic Provider v1.0", [string]$AlgorithmName = "RSA", [int]$KeyLength = 2048, [validateSet("Exchange","Signature")] [string]$KeySpec = "Exchange", [Alias('EKU')] [Security.Cryptography.Oid[]]$EnhancedKeyUsage, [Alias('KU')] [Security.Cryptography.X509Certificates.X509KeyUsageFlags]$KeyUsage, [Alias('SAN')] [String[]]$SubjectAlternativeName, [bool]$IsCA, [int]$PathLength = -1, [Security.Cryptography.X509Certificates.X509ExtensionCollection]$CustomExtension, [ValidateSet('MD5','SHA1','SHA256','SHA384','SHA512')] [string]$SignatureAlgorithm = "SHA1", [string]$FriendlyName, [Parameter(ParameterSetName = '__store')] [Security.Cryptography.X509Certificates.StoreLocation]$StoreLocation = "CurrentUser", [Parameter(Mandatory = $true, ParameterSetName = '__file')] [Alias('OutFile','OutPath','Out')] [IO.FileInfo]$Path, [Parameter(Mandatory = $true, ParameterSetName = '__file')] [Security.SecureString]$Password, [switch]$AllowSMIME, [switch]$Exportable ) $ErrorActionPreference = "Stop" if ([Environment]::OSVersion.Version.Major -lt 6) { $NotSupported = New-Object NotSupportedException -ArgumentList "Windows XP and Windows Server 2003 are not supported!" throw $NotSupported } $ExtensionsToAdd = @() #region constants # contexts New-Variable -Name UserContext -Value 0x1 -Option Constant New-Variable -Name MachineContext -Value 0x2 -Option Constant # encoding New-Variable -Name Base64Header -Value 0x0 -Option Constant New-Variable -Name Base64 -Value 0x1 -Option Constant New-Variable -Name Binary -Value 0x3 -Option Constant New-Variable -Name Base64RequestHeader -Value 0x4 -Option Constant # SANs New-Variable -Name OtherName -Value 0x1 -Option Constant New-Variable -Name RFC822Name -Value 0x2 -Option Constant New-Variable -Name DNSName -Value 0x3 -Option Constant New-Variable -Name DirectoryName -Value 0x5 -Option Constant New-Variable -Name URL -Value 0x7 -Option Constant New-Variable -Name IPAddress -Value 0x8 -Option Constant New-Variable -Name RegisteredID -Value 0x9 -Option Constant New-Variable -Name Guid -Value 0xa -Option Constant New-Variable -Name UPN -Value 0xb -Option Constant # installation options New-Variable -Name AllowNone -Value 0x0 -Option Constant New-Variable -Name AllowNoOutstandingRequest -Value 0x1 -Option Constant New-Variable -Name AllowUntrustedCertificate -Value 0x2 -Option Constant New-Variable -Name AllowUntrustedRoot -Value 0x4 -Option Constant # PFX export options New-Variable -Name PFXExportEEOnly -Value 0x0 -Option Constant New-Variable -Name PFXExportChainNoRoot -Value 0x1 -Option Constant New-Variable -Name PFXExportChainWithRoot -Value 0x2 -Option Constant #endregion #region Subject processing # http://msdn.microsoft.com/en-us/library/aa377051(VS.85).aspx $SubjectDN = New-Object -ComObject X509Enrollment.CX500DistinguishedName $SubjectDN.Encode($Subject, 0x0) #endregion #region Extensions #region Enhanced Key Usages processing if ($EnhancedKeyUsage) { $OIDs = New-Object -ComObject X509Enrollment.CObjectIDs $EnhancedKeyUsage | ForEach-Object { $OID = New-Object -ComObject X509Enrollment.CObjectID $OID.InitializeFromValue($_.Value) # http://msdn.microsoft.com/en-us/library/aa376785(VS.85).aspx $OIDs.Add($OID) } # http://msdn.microsoft.com/en-us/library/aa378132(VS.85).aspx $EKU = New-Object -ComObject X509Enrollment.CX509ExtensionEnhancedKeyUsage $EKU.InitializeEncode($OIDs) $ExtensionsToAdd += "EKU" } #endregion #region Key Usages processing if ($KeyUsage -ne $null) { $KU = New-Object -ComObject X509Enrollment.CX509ExtensionKeyUsage $KU.InitializeEncode([int]$KeyUsage) $KU.Critical = $true $ExtensionsToAdd += "KU" } #endregion #region Basic Constraints processing if ($PSBoundParameters.Keys.Contains("IsCA")) { # http://msdn.microsoft.com/en-us/library/aa378108(v=vs.85).aspx $BasicConstraints = New-Object -ComObject X509Enrollment.CX509ExtensionBasicConstraints if (!$IsCA) {$PathLength = -1} $BasicConstraints.InitializeEncode($IsCA,$PathLength) $BasicConstraints.Critical = $IsCA $ExtensionsToAdd += "BasicConstraints" } #endregion #region SAN processing if ($SubjectAlternativeName) { $SAN = New-Object -ComObject X509Enrollment.CX509ExtensionAlternativeNames $Names = New-Object -ComObject X509Enrollment.CAlternativeNames foreach ($altname in $SubjectAlternativeName) { $Name = New-Object -ComObject X509Enrollment.CAlternativeName if ($altname.Contains("@")) { $Name.InitializeFromString($RFC822Name,$altname) } else { try { $Bytes = [Net.IPAddress]::Parse($altname).GetAddressBytes() $Name.InitializeFromRawData($IPAddress,$Base64,[Convert]::ToBase64String($Bytes)) } catch { try { $Bytes = [Guid]::Parse($altname).ToByteArray() $Name.InitializeFromRawData($Guid,$Base64,[Convert]::ToBase64String($Bytes)) } catch { try { $Bytes = ([Security.Cryptography.X509Certificates.X500DistinguishedName]$altname).RawData $Name.InitializeFromRawData($DirectoryName,$Base64,[Convert]::ToBase64String($Bytes)) } catch {$Name.InitializeFromString($DNSName,$altname)} } } } $Names.Add($Name) } $SAN.InitializeEncode($Names) $ExtensionsToAdd += "SAN" } #endregion #region Custom Extensions if ($CustomExtension) { $count = 0 foreach ($ext in $CustomExtension) { # http://msdn.microsoft.com/en-us/library/aa378077(v=vs.85).aspx $Extension = New-Object -ComObject X509Enrollment.CX509Extension $EOID = New-Object -ComObject X509Enrollment.CObjectId $EOID.InitializeFromValue($ext.Oid.Value) $EValue = [Convert]::ToBase64String($ext.RawData) $Extension.Initialize($EOID,$Base64,$EValue) $Extension.Critical = $ext.Critical New-Variable -Name ("ext" + $count) -Value $Extension $ExtensionsToAdd += ("ext" + $count) $count++ } } #endregion #endregion #region Private Key # http://msdn.microsoft.com/en-us/library/aa378921(VS.85).aspx $PrivateKey = New-Object -ComObject X509Enrollment.CX509PrivateKey $PrivateKey.ProviderName = $ProviderName $AlgID = New-Object -ComObject X509Enrollment.CObjectId $AlgID.InitializeFromValue(([Security.Cryptography.Oid]$AlgorithmName).Value) $PrivateKey.Algorithm = $AlgID # http://msdn.microsoft.com/en-us/library/aa379409(VS.85).aspx $PrivateKey.KeySpec = switch ($KeySpec) {"Exchange" {1}; "Signature" {2}} $PrivateKey.Length = $KeyLength # key will be stored in current user certificate store switch ($PSCmdlet.ParameterSetName) { '__store' { $PrivateKey.MachineContext = if ($StoreLocation -eq "LocalMachine") {$true} else {$false} } '__file' { $PrivateKey.MachineContext = $false } } $PrivateKey.ExportPolicy = if ($Exportable) {1} else {0} $PrivateKey.Create() #endregion # http://msdn.microsoft.com/en-us/library/aa377124(VS.85).aspx $Cert = New-Object -ComObject X509Enrollment.CX509CertificateRequestCertificate if ($PrivateKey.MachineContext) { $Cert.InitializeFromPrivateKey($MachineContext,$PrivateKey,"") } else { $Cert.InitializeFromPrivateKey($UserContext,$PrivateKey,"") } $Cert.Subject = $SubjectDN $Cert.Issuer = $Cert.Subject $Cert.NotBefore = $NotBefore $Cert.NotAfter = $NotAfter foreach ($item in $ExtensionsToAdd) {$Cert.X509Extensions.Add((Get-Variable -Name $item -ValueOnly))} if (![string]::IsNullOrEmpty($SerialNumber)) { if ($SerialNumber -match "[^0-9a-fA-F]") {throw "Invalid serial number specified."} if ($SerialNumber.Length % 2) {$SerialNumber = "0" + $SerialNumber} $Bytes = $SerialNumber -split "(.{2})" | Where-Object {$_} | ForEach-Object{[Convert]::ToByte($_,16)} $ByteString = [Convert]::ToBase64String($Bytes) $Cert.SerialNumber.InvokeSet($ByteString,1) } if ($AllowSMIME) {$Cert.SmimeCapabilities = $true} $SigOID = New-Object -ComObject X509Enrollment.CObjectId $SigOID.InitializeFromValue(([Security.Cryptography.Oid]$SignatureAlgorithm).Value) $Cert.SignatureInformation.HashAlgorithm = $SigOID # completing certificate request template building $Cert.Encode() # interface: http://msdn.microsoft.com/en-us/library/aa377809(VS.85).aspx $Request = New-Object -ComObject X509Enrollment.CX509enrollment $Request.InitializeFromRequest($Cert) $Request.CertificateFriendlyName = $FriendlyName $endCert = $Request.CreateRequest($Base64) $Request.InstallResponse($AllowUntrustedCertificate,$endCert,$Base64,"") switch ($PSCmdlet.ParameterSetName) { '__file' { $PFXString = $Request.CreatePFX( [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($Password)), $PFXExportEEOnly, $Base64 ) Set-Content -Path $Path -Value ([Convert]::FromBase64String($PFXString)) -Encoding Byte } } [Byte[]]$CertBytes = [Convert]::FromBase64String($endCert) New-Object Security.Cryptography.X509Certificates.X509Certificate2 @(,$CertBytes) } # SIG # Begin signature block # MIIcgAYJKoZIhvcNAQcCoIIccTCCHG0CAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCAmMWwZnveROeiP # Okrv0onByV5n94ickqih9JS7E9E/HKCCF4owggUTMIID+6ADAgECAhABn3Jtjtqs # sQ4D4Fge9iqaMA0GCSqGSIb3DQEBCwUAMHIxCzAJBgNVBAYTAlVTMRUwEwYDVQQK # EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xMTAvBgNV # BAMTKERpZ2lDZXJ0IFNIQTIgQXNzdXJlZCBJRCBDb2RlIFNpZ25pbmcgQ0EwHhcN # MTUxMjE4MDAwMDAwWhcNMTYxMjIyMTIwMDAwWjBQMQswCQYDVQQGEwJMVjENMAsG # A1UEBxMEUmlnYTEYMBYGA1UEChMPU3lzYWRtaW5zIExWIElLMRgwFgYDVQQDEw9T # eXNhZG1pbnMgTFYgSUswggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDo # UVviPttwGnu8WAEbA2zvYj3+eJLxrpWtcokvyZALEd8hf7m19yCIruChB3b3Cszt # OMjgV+a4MoPNEjMdnbFVstO+nCxbh/J1W6ArjqEIaYX6H4ZJNwfFD7S22JNeKHW7 # /Z//jdsPSTRvSugWuGFzix0DxdfTDATuq10J6ivi1Tk9DZJpMfEKMnz6ze24UfJU # FX1XxcbeDgTdK2nd1RGAMKnxYQhn4Gzv+TrbLJWs976aLR/tJ8td4UqtlK/BE0PB # S3G7Xb4dNjm4e1nVFz7FNf6DqQQ34ZDk+XgVVQINxNbB2WmkOMEJFX2G3+F539d4 # V6EfRAF0+v1U9Ofm1m6TAgMBAAGjggHFMIIBwTAfBgNVHSMEGDAWgBRaxLl7Kgqj # pepxA8Bg+S32ZXUOWDAdBgNVHQ4EFgQU/3BF2aoFQv5rK3jP1wW8I1t/uoMwDgYD # VR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMHcGA1UdHwRwMG4wNaAz # oDGGL2h0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9zaGEyLWFzc3VyZWQtY3MtZzEu # Y3JsMDWgM6Axhi9odHRwOi8vY3JsNC5kaWdpY2VydC5jb20vc2hhMi1hc3N1cmVk # LWNzLWcxLmNybDBMBgNVHSAERTBDMDcGCWCGSAGG/WwDATAqMCgGCCsGAQUFBwIB # FhxodHRwczovL3d3dy5kaWdpY2VydC5jb20vQ1BTMAgGBmeBDAEEATCBhAYIKwYB # BQUHAQEEeDB2MCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20w # TgYIKwYBBQUHMAKGQmh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2Vy # dFNIQTJBc3N1cmVkSURDb2RlU2lnbmluZ0NBLmNydDAMBgNVHRMBAf8EAjAAMA0G # CSqGSIb3DQEBCwUAA4IBAQBRqP0FyPMXdmGf4C+ubIeHSeFRcunS6kFdyokn8tKK # HFqAvea8QCmdFqMPTTet0WK/2O8RiiscWADDbmyHTC9KMNOufeabWtNCbwwaBeg0 # xir8eo2deX1JVWfji4ZdwHTlqJR5hnCM+i1iD60zWOx7+8WAF6toCs5O1+CDqt5P # hvv0Re0Y17DeFWe9NNanOdy/t+cpTuJZmX3TR5dhRZJTMZZnTdzi4qTWIAaRX4m/ # fUehKfBwd5pzoZwlZ0RC/5RnRMpdUtankwKPdrSjLPSObJwDwxoZvZwpAKhwm1wa # 49Rv1bHg/r090IrClnAUA6Os1PJAYRWMU8ayMMQuM496MIIFMDCCBBigAwIBAgIQ # BAkYG1/Vu2Z1U0O1b5VQCDANBgkqhkiG9w0BAQsFADBlMQswCQYDVQQGEwJVUzEV # MBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29t # MSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0EwHhcNMTMxMDIy # MTIwMDAwWhcNMjgxMDIyMTIwMDAwWjByMQswCQYDVQQGEwJVUzEVMBMGA1UEChMM # RGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMTEwLwYDVQQD # EyhEaWdpQ2VydCBTSEEyIEFzc3VyZWQgSUQgQ29kZSBTaWduaW5nIENBMIIBIjAN # BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA+NOzHH8OEa9ndwfTCzFJGc/Q+0WZ # sTrbRPV/5aid2zLXcep2nQUut4/6kkPApfmJ1DcZ17aq8JyGpdglrA55KDp+6dFn # 08b7KSfH03sjlOSRI5aQd4L5oYQjZhJUM1B0sSgmuyRpwsJS8hRniolF1C2ho+mI # LCCVrhxKhwjfDPXiTWAYvqrEsq5wMWYzcT6scKKrzn/pfMuSoeU7MRzP6vIK5Fe7 # SrXpdOYr/mzLfnQ5Ng2Q7+S1TqSp6moKq4TzrGdOtcT3jNEgJSPrCGQ+UpbB8g8S # 9MWOD8Gi6CxR93O8vYWxYoNzQYIH5DiLanMg0A9kczyen6Yzqf0Z3yWT0QIDAQAB # o4IBzTCCAckwEgYDVR0TAQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMCAYYwEwYD # VR0lBAwwCgYIKwYBBQUHAwMweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhho # dHRwOi8vb2NzcC5kaWdpY2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNl # cnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwgYEG # A1UdHwR6MHgwOqA4oDaGNGh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2Vy # dEFzc3VyZWRJRFJvb3RDQS5jcmwwOqA4oDaGNGh0dHA6Ly9jcmwzLmRpZ2ljZXJ0 # LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcmwwTwYDVR0gBEgwRjA4Bgpg # hkgBhv1sAAIEMCowKAYIKwYBBQUHAgEWHGh0dHBzOi8vd3d3LmRpZ2ljZXJ0LmNv # bS9DUFMwCgYIYIZIAYb9bAMwHQYDVR0OBBYEFFrEuXsqCqOl6nEDwGD5LfZldQ5Y # MB8GA1UdIwQYMBaAFEXroq/0ksuCMS1Ri6enIZ3zbcgPMA0GCSqGSIb3DQEBCwUA # A4IBAQA+7A1aJLPzItEVyCx8JSl2qB1dHC06GsTvMGHXfgtg/cM9D8Svi/3vKt8g # VTew4fbRknUPUbRupY5a4l4kgU4QpO4/cY5jDhNLrddfRHnzNhQGivecRk5c/5Cx # GwcOkRX7uq+1UcKNJK4kxscnKqEpKBo6cSgCPC6Ro8AlEeKcFEehemhor5unXCBc # 2XGxDI+7qPjFEmifz0DLQESlE/DmZAwlCEIysjaKJAL+L3J+HNdJRZboWR3p+nRk # a7LrZkPas7CM1ekN3fYBIM6ZMWM9CBoYs4GbT8aTEAb8B4H6i9r5gkn3Ym6hU/oS # lBiFLpKR6mhsRDKyZqHnGKSaZFHvMIIGajCCBVKgAwIBAgIQAwGaAjr/WLFr1tXq # 5hfwZjANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGln # aUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhE # aWdpQ2VydCBBc3N1cmVkIElEIENBLTEwHhcNMTQxMDIyMDAwMDAwWhcNMjQxMDIy # MDAwMDAwWjBHMQswCQYDVQQGEwJVUzERMA8GA1UEChMIRGlnaUNlcnQxJTAjBgNV # BAMTHERpZ2lDZXJ0IFRpbWVzdGFtcCBSZXNwb25kZXIwggEiMA0GCSqGSIb3DQEB # AQUAA4IBDwAwggEKAoIBAQCjZF38fLPggjXg4PbGKuZJdTvMbuBTqZ8fZFnmfGt/ # a4ydVfiS457VWmNbAklQ2YPOb2bu3cuF6V+l+dSHdIhEOxnJ5fWRn8YUOawk6qhL # LJGJzF4o9GS2ULf1ErNzlgpno75hn67z/RJ4dQ6mWxT9RSOOhkRVfRiGBYxVh3lI # RvfKDo2n3k5f4qi2LVkCYYhhchhoubh87ubnNC8xd4EwH7s2AY3vJ+P3mvBMMWSN # 4+v6GYeofs/sjAw2W3rBerh4x8kGLkYQyI3oBGDbvHN0+k7Y/qpA8bLOcEaD6dpA # oVk62RUJV5lWMJPzyWHM0AjMa+xiQpGsAsDvpPCJEY93AgMBAAGjggM1MIIDMTAO # BgNVHQ8BAf8EBAMCB4AwDAYDVR0TAQH/BAIwADAWBgNVHSUBAf8EDDAKBggrBgEF # BQcDCDCCAb8GA1UdIASCAbYwggGyMIIBoQYJYIZIAYb9bAcBMIIBkjAoBggrBgEF # BQcCARYcaHR0cHM6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzCCAWQGCCsGAQUFBwIC # MIIBVh6CAVIAQQBuAHkAIAB1AHMAZQAgAG8AZgAgAHQAaABpAHMAIABDAGUAcgB0 # AGkAZgBpAGMAYQB0AGUAIABjAG8AbgBzAHQAaQB0AHUAdABlAHMAIABhAGMAYwBl # AHAAdABhAG4AYwBlACAAbwBmACAAdABoAGUAIABEAGkAZwBpAEMAZQByAHQAIABD # AFAALwBDAFAAUwAgAGEAbgBkACAAdABoAGUAIABSAGUAbAB5AGkAbgBnACAAUABh # AHIAdAB5ACAAQQBnAHIAZQBlAG0AZQBuAHQAIAB3AGgAaQBjAGgAIABsAGkAbQBp # AHQAIABsAGkAYQBiAGkAbABpAHQAeQAgAGEAbgBkACAAYQByAGUAIABpAG4AYwBv # AHIAcABvAHIAYQB0AGUAZAAgAGgAZQByAGUAaQBuACAAYgB5ACAAcgBlAGYAZQBy # AGUAbgBjAGUALjALBglghkgBhv1sAxUwHwYDVR0jBBgwFoAUFQASKxOYspkH7R7f # or5XDStnAs0wHQYDVR0OBBYEFGFaTSS2STKdSip5GoNL9B6Jwcp9MH0GA1UdHwR2 # MHQwOKA2oDSGMmh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEFzc3Vy # ZWRJRENBLTEuY3JsMDigNqA0hjJodHRwOi8vY3JsNC5kaWdpY2VydC5jb20vRGln # aUNlcnRBc3N1cmVkSURDQS0xLmNybDB3BggrBgEFBQcBAQRrMGkwJAYIKwYBBQUH # MAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBBBggrBgEFBQcwAoY1aHR0cDov # L2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEQ0EtMS5jcnQw # DQYJKoZIhvcNAQEFBQADggEBAJ0lfhszTbImgVybhs4jIA+Ah+WI//+x1GosMe06 # FxlxF82pG7xaFjkAneNshORaQPveBgGMN/qbsZ0kfv4gpFetW7easGAm6mlXIV00 # Lx9xsIOUGQVrNZAQoHuXx/Y/5+IRQaa9YtnwJz04HShvOlIJ8OxwYtNiS7Dgc6aS # wNOOMdgv420XEwbu5AO2FKvzj0OncZ0h3RTKFV2SQdr5D4HRmXQNJsQOfxu19aDx # xncGKBXp2JPlVRbwuwqrHNtcSCdmyKOLChzlldquxC5ZoGHd2vNtomHpigtt7BIY # vfdVVEADkitrwlHCCkivsNRu4PQUCjob4489yq9qjXvc2EQwggbNMIIFtaADAgEC # AhAG/fkDlgOt6gAK6z8nu7obMA0GCSqGSIb3DQEBBQUAMGUxCzAJBgNVBAYTAlVT # MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j # b20xJDAiBgNVBAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0wNjEx # MTAwMDAwMDBaFw0yMTExMTAwMDAwMDBaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQK # EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNV # BAMTGERpZ2lDZXJ0IEFzc3VyZWQgSUQgQ0EtMTCCASIwDQYJKoZIhvcNAQEBBQAD # ggEPADCCAQoCggEBAOiCLZn5ysJClaWAc0Bw0p5WVFypxNJBBo/JM/xNRZFcgZ/t # LJz4FlnfnrUkFcKYubR3SdyJxArar8tea+2tsHEx6886QAxGTZPsi3o2CAOrDDT+ # GEmC/sfHMUiAfB6iD5IOUMnGh+s2P9gww/+m9/uizW9zI/6sVgWQ8DIhFonGcIj5 # BZd9o8dD3QLoOz3tsUGj7T++25VIxO4es/K8DCuZ0MZdEkKB4YNugnM/JksUkK5Z # ZgrEjb7SzgaurYRvSISbT0C58Uzyr5j79s5AXVz2qPEvr+yJIvJrGGWxwXOt1/HY # zx4KdFxCuGh+t9V3CidWfA9ipD8yFGCV/QcEogkCAwEAAaOCA3owggN2MA4GA1Ud # DwEB/wQEAwIBhjA7BgNVHSUENDAyBggrBgEFBQcDAQYIKwYBBQUHAwIGCCsGAQUF # BwMDBggrBgEFBQcDBAYIKwYBBQUHAwgwggHSBgNVHSAEggHJMIIBxTCCAbQGCmCG # SAGG/WwAAQQwggGkMDoGCCsGAQUFBwIBFi5odHRwOi8vd3d3LmRpZ2ljZXJ0LmNv # bS9zc2wtY3BzLXJlcG9zaXRvcnkuaHRtMIIBZAYIKwYBBQUHAgIwggFWHoIBUgBB # AG4AeQAgAHUAcwBlACAAbwBmACAAdABoAGkAcwAgAEMAZQByAHQAaQBmAGkAYwBh # AHQAZQAgAGMAbwBuAHMAdABpAHQAdQB0AGUAcwAgAGEAYwBjAGUAcAB0AGEAbgBj # AGUAIABvAGYAIAB0AGgAZQAgAEQAaQBnAGkAQwBlAHIAdAAgAEMAUAAvAEMAUABT # ACAAYQBuAGQAIAB0AGgAZQAgAFIAZQBsAHkAaQBuAGcAIABQAGEAcgB0AHkAIABB # AGcAcgBlAGUAbQBlAG4AdAAgAHcAaABpAGMAaAAgAGwAaQBtAGkAdAAgAGwAaQBh # AGIAaQBsAGkAdAB5ACAAYQBuAGQAIABhAHIAZQAgAGkAbgBjAG8AcgBwAG8AcgBh # AHQAZQBkACAAaABlAHIAZQBpAG4AIABiAHkAIAByAGUAZgBlAHIAZQBuAGMAZQAu # MAsGCWCGSAGG/WwDFTASBgNVHRMBAf8ECDAGAQH/AgEAMHkGCCsGAQUFBwEBBG0w # azAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEMGCCsGAQUF # BzAChjdodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRBc3N1cmVk # SURSb290Q0EuY3J0MIGBBgNVHR8EejB4MDqgOKA2hjRodHRwOi8vY3JsMy5kaWdp # Y2VydC5jb20vRGlnaUNlcnRBc3N1cmVkSURSb290Q0EuY3JsMDqgOKA2hjRodHRw # Oi8vY3JsNC5kaWdpY2VydC5jb20vRGlnaUNlcnRBc3N1cmVkSURSb290Q0EuY3Js # MB0GA1UdDgQWBBQVABIrE5iymQftHt+ivlcNK2cCzTAfBgNVHSMEGDAWgBRF66Kv # 9JLLgjEtUYunpyGd823IDzANBgkqhkiG9w0BAQUFAAOCAQEARlA+ybcoJKc4HbZb # Ka9Sz1LpMUerVlx71Q0LQbPv7HUfdDjyslxhopyVw1Dkgrkj0bo6hnKtOHisdV0X # FzRyR4WUVtHruzaEd8wkpfMEGVWp5+Pnq2LN+4stkMLA0rWUvV5PsQXSDj0aqRRb # poYxYqioM+SbOafE9c4deHaUJXPkKqvPnHZL7V/CSxbkS3BMAIke/MV5vEwSV/5f # 4R68Al2o/vsHOE8Nxl2RuQ9nRc3Wg+3nkg2NsWmMT/tZ4CMP0qquAHzunEIOz5HX # J7cW7g/DvXwKoO4sCFWFIrjrGBpN/CohrUkxg0eVd3HcsRtLSxwQnHcUwZ1PL1qV # CCkQJjGCBEwwggRIAgEBMIGGMHIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdp # Q2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xMTAvBgNVBAMTKERp # Z2lDZXJ0IFNIQTIgQXNzdXJlZCBJRCBDb2RlIFNpZ25pbmcgQ0ECEAGfcm2O2qyx # DgPgWB72KpowDQYJYIZIAWUDBAIBBQCggYQwGAYKKwYBBAGCNwIBDDEKMAigAoAA # oQKAADAZBgkqhkiG9w0BCQMxDAYKKwYBBAGCNwIBBDAcBgorBgEEAYI3AgELMQ4w # DAYKKwYBBAGCNwIBFTAvBgkqhkiG9w0BCQQxIgQgCCPKt4WoiY1sbAfnZKmxtY0e # oDPZ+qZBOsAu+KHmiGQwDQYJKoZIhvcNAQEBBQAEggEAwBaoh3PcM+OcihX5zILU # 8lE2Ph0f3sEPqmr/5Tzs/S6XDZqy2ux/Uh3sDLcsdk9gywFhBOr2g0G4AFucN+N/ # E6LFPKPS1po34+wK6w7Z8mcRU+7vNyxPxc7Lycm2HzefwYaFcSA6xbAhHvNiNLd+ # 1/BumSqlJPBwdDr9H4Ri86CSYj7xRDpafZ6WncqgAMaXsYWsTiwbbiekIcExvZgg # BnQ3fEDNBdmK0WZCdZX93DZEYKLdSH6r6lSWRW5IHgzlCWL5OP415GzkHgxthx2m # utxvJHuF+LpZfDACQswPX61XwGo61Y6qX+l7g4clG5SeFoK8wbgrR12P4EgECKzy # 66GCAg8wggILBgkqhkiG9w0BCQYxggH8MIIB+AIBATB2MGIxCzAJBgNVBAYTAlVT # MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j # b20xITAfBgNVBAMTGERpZ2lDZXJ0IEFzc3VyZWQgSUQgQ0EtMQIQAwGaAjr/WLFr # 1tXq5hfwZjAJBgUrDgMCGgUAoF0wGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEHATAc # BgkqhkiG9w0BCQUxDxcNMTYwOTExMTkwMzE5WjAjBgkqhkiG9w0BCQQxFgQUBFvE # Qx3Ondofr5v3IdqidXufRVUwDQYJKoZIhvcNAQEBBQAEggEANXU9soYMqHDm1v0E # QcIMRTYPkv2xB/xDIc4JmhfFKMO3SUDA1m6lS2w6WWbhNu0GRTFT2Bed712y8qXI # t9uO1BDTxDyl0lBYAKjUDnWI17cZpHEcx5snT1D2CYv29TfOpH9/+gzlddWrdB9q # VTcEo1/hFH0B4ffgE8YNcw4jg+e7b3nKpGjBDwr6SqMWwTZv37qj/xvLubtzEKEi # i1g6VG4tnvWcH2TD5bGmtzLZ8N1fyktdju8B5oscnSYeiX/Z26ZM2JN8coHNSW1k # krCJqqlTJrihEOvqs/NiDKyq1lBh3Es/o6LaNxYDeHr/8ntIqfMpbI6ZB+7RW0XC # B6o+Cw== # SIG # End signature block ================================================ FILE: vagrant/README.rst ================================================ ADFS Setup Scripts for vagrant ============================== This directory contains scripts used by Vagrant while bringing up test virtual machines. you can use these scripts as an example for setting up your own ADFS environment. .. warning:: These scripts are meant for setting up a lab and are not meant for setting up a secure production environment.