Showing preview only (419K chars total). Download the full file or copy to clipboard to get everything.
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 <https://docs.djangoproject.com/en/5.1/releases/4.1/#features-deprecated-in-4-1>`_.
- Using GET requests:
.. code-block:: html
<a href="{% url 'django_auth_adfs:logout' %}">Logout</a>
<a href="{% url 'django_auth_adfs:login' %}">Login</a>
<a href="{% url 'django_auth_adfs:login-no-sso' %}">Login (no SSO)</a>
- Using POST requests:
.. code-block:: html+django
<form method="post" action="{% url 'django_auth_adfs:logout' %}">
{% csrf_token %}
<button type="submit">Logout</button>
</form>
<form method="post" action="{% url 'django_auth_adfs:login' %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ next }}">
<button type="submit">Login</button>
</form>
<form method="post" action="{% url 'django_auth_adfs:login-no-sso' %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ next }}">
<button type="submit">Login (no SSO)</button>
</form>
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 %}
<h1 id="site-name"><a href="{% url 'admin:index' %}">Polls Administration</a></h1>
{% endblock %}
{% block nav-global %}{% endblock %}
================================================
FILE: demo/adfs/polls/templates/polls/detail.html
================================================
{% extends "base.html" %}
{% block content %}
<h3>{{ question.question_text }}</h3>
<div class="row">
<div class="col">
<ul class="list-group" style="max-width: 400px">
{% for choice in question.choice_set.all %}
<li class="list-group-item d-flex justify-content-between align-items-center">{{ choice.choice_text }} <span class="badge badge-primary badge-pill">{{ choice.votes }}</span></li>
{% endfor %}
</ul>
</div>
</div>
<div class="row mt-2">
<div class="col">
<a class="btn btn-primary" role="button" href="{% url 'polls:vote' question.id %}">Vote</a>
</div>
</div>
{% endblock %}
================================================
FILE: demo/adfs/polls/templates/polls/index.html
================================================
{% extends "base.html" %}
{% block content %}
<h3>Available polls</h3>
{% if latest_question_list %}
<div class="list-group">
{% for question in latest_question_list %}
<a class="list-group-item list-group-item-action" href="{% url 'polls:detail' question.id %}">{{ question.question_text }}</a>
{% endfor %}
</div>
{% else %}
<p>No polls are available.</p>
{% endif %}
{% endblock %}
================================================
FILE: demo/adfs/polls/templates/polls/vote.html
================================================
{% extends "base.html" %}
{% block content %}
<h3>{{ question.question_text }}</h3>
{% if error_message %}<p><strong>{{ error_message }}</strong></p>{% endif %}
<form action="{% url 'polls:vote' question.id %}" method="post">
{% csrf_token %}
{% for choice in question.choice_set.all %}
<input type="radio" name="choice" id="choice{{ forloop.counter }}" value="{{ choice.id }}" />
<label for="choice{{ forloop.counter }}">{{ choice.choice_text }}</label><br />
{% endfor %}
<input type="submit" value="Vote" class="btn btn-primary" />
</form>
{% 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('<int:pk>/', views.DetailView.as_view(), name='detail'),
path('<int:pk>/vote/', views.VoteView.as_view(), name='vote'),
# path('<int:pk>/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 %}
<!doctype html>
<html lang="en">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>{% block title %}Polls App{% endblock %}</title>
<link rel="stylesheet" type="text/css" href="{% static 'bootstrap.min.css' %}" />
</head>
<body>
<div class="container">
<nav class="navbar navbar-expand-sm navbar-dark bg-dark">
<a class="navbar-brand" href="/">Polls</a>
<div class="collapse navbar-collapse" id="navbarTogglerDemo03">
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a class="nav-link" href="{% url 'polls:index' %}">Polls</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'admin:index' %}">Admin</a>
</li>
{% if user.is_authenticated %}
<li>
<a class="nav-link" href="{% url 'django_auth_adfs:logout' %}">Logout</a>
</li>
{% else %}
<li>
<a class="nav-link" href="{% url 'django_auth_adfs:login' %}">Login</a>
</li>
<li>
<a class="nav-link" href="{% url 'django_auth_adfs:login-no-sso' %}">Login (no SSO)</a>
</li>
{% endif %}
</ul>
</div>
</nav>
<div class="row mt-1">
<div class="col-sm">
{% block content %}{% endblock %}
</div>
</div>
<div class="row mt-2">
<div class="col-sm">
<div class="card bg-secondary text-white fixed-bottom">
<div class="card-header">
Current User Info
</div>
<div class="card-body">
<pre class="mb-0 pr-5 card-text text-white float-left">
id = {{ user.id }}
username = {{ user.username }}
first_name = {{ user.first_name }}
last_name = {{ user.last_name }}
email = {{ user.email }}
</pre>
<pre class="mb-0 pr-5 card-text text-white float-left">
is_authenticated = {{ user.is_authenticated }}
is_staff = {{ user.is_staff }}
is_active = {{ user.is_active }}
is_superuser = {{ user.is_superuser }}
</pre>
<pre class="mb-0 card-text text-white float-left">
last_login = {{ user.last_login }}
date_joined = {{ user.date_joined }}
</pre>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
================================================
FILE: demo/adfs/templates/home.html
================================================
{% extends 'base.html' %}
{% block title %}Home{% endblock %}
{% block content %}
<div class="jumbotron jumbotron-fluid">
<div class="container">
<h1 class="display-4">Welcome to the polls app</h1>
<p class="lead">Use the menu above to navigate</p>
</div>
</div>
{% 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 %}
<h1 id="site-name"><a href="{% url 'admin:index' %}">Polls Administration</a></h1>
{% endblock %}
{% block nav-global %}{% endblock %}
================================================
FILE: demo/formsbased/polls/templates/polls/detail.html
================================================
{% extends "base.html" %}
{% block content %}
<h3>{{ question.question_text }}</h3>
<div class="row">
<div class="col">
<ul class="list-group" style="max-width: 400px">
{% for choice in question.choice_set.all %}
<li class="list-group-item d-flex justify-content-between align-items-center">{{ choice.choice_text }} <span class="badge badge-primary badge-pill">{{ choice.votes }}</span></li>
{% endfor %}
</ul>
</div>
</div>
<div class="row mt-2">
<div class="col">
<a class="btn btn-primary" role="button" href="{% url 'polls:vote' question.id %}">Vote</a>
</div>
</div>
{% endblock %}
================================================
FILE: demo/formsbased/polls/templates/polls/index.html
================================================
{% extends "base.html" %}
{% block content %}
<h3>Available polls</h3>
{% if latest_question_list %}
<div class="list-group">
{% for question in latest_question_list %}
<a class="list-group-item list-group-item-action" href="{% url 'polls:detail' question.id %}">{{ question.question_text }}</a>
{% endfor %}
</div>
{% else %}
<p>No polls are available.</p>
{% endif %}
{% endblock %}
================================================
FILE: demo/formsbased/polls/templates/polls/vote.html
================================================
{% extends "base.html" %}
{% block content %}
<h3>{{ question.question_text }}</h3>
{% if error_message %}<p><strong>{{ error_message }}</strong></p>{% endif %}
<form action="{% url 'polls:vote' question.id %}" method="post">
{% csrf_token %}
{% for choice in question.choice_set.all %}
<input type="radio" name="choice" id="choice{{ forloop.counter }}" value="{{ choice.id }}" />
<label for="choice{{ forloop.counter }}">{{ choice.choice_text }}</label><br />
{% endfor %}
<input type="submit" value="Vote" class="btn btn-primary" />
</form>
{% 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('<int:pk>/', views.DetailView.as_view(), name='detail'),
path('<int:pk>/vote/', views.VoteView.as_view(), name='vote'),
# path('<int:pk>/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 %}
<!doctype html>
<html lang="en">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>{% block title %}Polls App{% endblock %}</title>
<link rel="stylesheet" type="text/css" href="{% static 'bootstrap.min.css' %}" />
</head>
<body>
<div class="container">
<nav class="navbar navbar-expand-sm navbar-dark bg-dark">
<a class="navbar-brand" href="/">Polls</a>
<div class="collapse navbar-collapse" id="navbarTogglerDemo03">
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a class="nav-link" href="{% url 'polls:index' %}">Polls</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'admin:index' %}">Admin</a>
</li>
<li>
{% if user.is_authenticated %}
<a class="nav-link" href="{% url 'logout' %}">Logout</a>
{% else %}
<a class="nav-link" href="{% url 'login' %}">Login</a>
{% endif %}
</li>
</ul>
</div>
</nav>
<div class="row mt-1">
<div class="col-sm">
{% block content %}{% endblock %}
</div>
</div>
<div class="row mt-2">
<div class="col-sm">
<div class="card bg-secondary text-white fixed-bottom">
<div class="card-header">
Current User Info
</div>
<div class="card-body">
<pre class="mb-0 pr-5 card-text text-white float-left">
id = {{ user.id }}
username = {{ user.username }}
first_name = {{ user.first_name }}
last_name = {{ user.last_name }}
email = {{ user.email }}
</pre>
<pre class="mb-0 pr-5 card-text text-white float-left">
is_authenticated = {{ user.is_authenticated }}
is_staff = {{ user.is_staff }}
is_active = {{ user.is_active }}
is_superuser = {{ user.is_superuser }}
</pre>
<pre class="mb-0 card-text text-white float-left">
last_login = {{ user.last_login }}
date_joined = {{ user.date_joined }}
</pre>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
================================================
FILE: demo/formsbased/templates/home.html
================================================
{% extends 'base.html' %}
{% block title %}Home{% endblock %}
{% block content %}
<div class="jumbotron jumbotron-fluid">
<div class="container">
<h1 class="display-4">Welcome to the polls app</h1>
<p class="lead">Use the menu above to navigate</p>
</div>
</div>
{% endblock %}
================================================
FILE: demo/formsbased/templates/registration/logged_out.html
================================================
{% extends 'base.html' %}
{% block title %}See you!{% endblock %}
{% block content %}
<h2>Logged out</h2>
<p>You have been successfully logged out.</p>
<p><a href="{% url 'login' %}">Log in</a> again.</p>
{% endblock %}
================================================
FILE: demo/formsbased/templates/registration/login.html
================================================
{% extends "base.html" %}
{% block content %}
<h3>Login</h3>
{% if form.errors %}
<p>Your username and password didn't match. Please try again.</p>
{% endif %}
{% if next %}
{% if user.is_authenticated %}
<p>Your account doesn't have access to this page. To proceed,
please login with an account that has access.</p>
{% else %}
<p>Please login to see this page.</p>
{% endif %}
{% endif %}
<form style="width: 300px" method="post" action="{% url 'login' %}">
{% csrf_token %}
<div class="form-group row">
<div class="col-sm-12">
<input type="text" name="username" class="form-control" id="inputUsername3" placeholder="Username">
</div>
</div>
<div class="form-group row">
<div class="col-sm-12">
<input type="password" name="password" class="form-control" id="inputPassword3" placeholder="Password">
</div>
</div>
<div class="form-group row">
<div class="col-sm-12">
<button type="submit" class="btn btn-primary">Login</button>
</div>
</div>
<input type="hidden" name="next" value="{{ next }}" />
</form>
{% 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
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login Failure</title>
<style>
#message {
position: fixed;
top: 50%;
left: 50%;
width: 30em;
height: 3em;
margin-top: -9em;
margin-left: -15em;
border: 1px solid #ccc;
background-color: #f3f3f3;
font-family: Lato, proxima-nova, Helvetica Neue, Arial, sans-serif;
text-align: center;
display: flex;
justify-content: center;
align-content: center;
flex-direction: column;
}
</style>
</head>
<body>
<div id="message">
<strong>{{ error_message }}</strong>
</div>
</body>
</html>
================================================
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 <target>' where <target> 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 <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 <https://portal.azure.com>`_. 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
# "<project> v<release> 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 <link> 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 <https://www.virtualbox.org/>`__.
* A working `vagrant <https://www.vagrantup.com/>`__ installation. On Debian 11 (bullseye) if you use the `stock vagrant package <https://packages.debian.org/bullseye/vagrant>`__ 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 <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 <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 <https://docs.djangoproject.com/en/dev/ref/settings/#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 <https://simpleisbetterthancomplex.com/tutorial/2016/07/22/how-to-extend-django-user-model.html#abstractuser>`_
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 <https://docs.djangoproject.com/en/3.0/ref/databases/#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 <https://docs.djangoproject.com/en/dev/ref/settings/#secure-proxy-ssl-header>`_.
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 <https://docs.djangoproject.com/en/5.1/releases/4.1/#features-deprecated-in-4-1>`_.
- Using GET requests:
.. code-block:: html
<a href="{% url 'django_auth_adfs:logout' %}">Logout</a>
<a href="{% url 'django_auth_adfs:login' %}">Login</a>
<a href="{% url 'django_auth_adfs:login-no-sso' %}">Login (no SSO)</a>
- Using POST requests:
.. code-block:: html+django
<form method="post" action="{% url 'django_auth_adfs:logout' %}">
{% csrf_token %}
<button type="submit">Logout</button>
</form>
<form method="post" action="{% url 'django_auth_adfs:login' %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ next }}">
<button type="submit">Login</button>
</form>
<form method="post" action="{% url 'django_auth_adfs:login-no-sso' %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ next }}">
<button type="submit">Login (no SSO)</button>
</form>
================================================
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 ^<target^>` where ^<target^> 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 <https://tools.ietf.org/html/rfc6749>`__?
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 <https://openid.net/specs/openid-connect-core-1_0.html>`__
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 <https://tools.ietf.org/html/rfc6749#section-4.1>`__ specifies
the `Authorization Code Grant <https://tools.ietf.org/html/rfc6749#section-4.1>`__ 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 thi
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
SYMBOL INDEX (200 symbols across 29 files)
FILE: demo/adfs/polls/admin.py
class ChoiceInline (line 6) | class ChoiceInline(admin.TabularInline):
class QuestionAdmin (line 11) | class QuestionAdmin(admin.ModelAdmin):
FILE: demo/adfs/polls/api/filters.py
class QuestionFilter (line 5) | class QuestionFilter(django_filters.FilterSet):
class Meta (line 6) | class Meta:
class ChoiceFilter (line 11) | class ChoiceFilter(django_filters.FilterSet):
class Meta (line 12) | class Meta:
FILE: demo/adfs/polls/api/serializers.py
class QuestionSerializer (line 5) | class QuestionSerializer(serializers.ModelSerializer):
class Meta (line 6) | class Meta:
class ChoiceSerializer (line 11) | class ChoiceSerializer(serializers.ModelSerializer):
class Meta (line 14) | class Meta:
FILE: demo/adfs/polls/api/views.py
class QuestionViewSet (line 11) | class QuestionViewSet(ModelViewSet):
class ChoiceViewSet (line 17) | class ChoiceViewSet(ModelViewSet):
method vote (line 23) | def vote(self, request, pk=None):
FILE: demo/adfs/polls/apps.py
class PollsConfig (line 4) | class PollsConfig(AppConfig):
FILE: demo/adfs/polls/migrations/0001_initial.py
class Migration (line 7) | class Migration(migrations.Migration):
FILE: demo/adfs/polls/models.py
class Question (line 7) | class Question(models.Model):
method __str__ (line 11) | def __str__(self):
method was_published_recently (line 14) | def was_published_recently(self):
class Choice (line 22) | class Choice(models.Model):
method __str__ (line 27) | def __str__(self):
method vote (line 30) | def vote(self):
FILE: demo/adfs/polls/views.py
class IndexView (line 11) | class IndexView(generic.ListView):
method get_queryset (line 15) | def get_queryset(self):
class DetailView (line 25) | class DetailView(generic.DetailView):
method get_queryset (line 29) | def get_queryset(self):
class VoteView (line 36) | class VoteView(LoginRequiredMixin, generic.DetailView):
method get_queryset (line 40) | def get_queryset(self):
method post (line 46) | def post(self, request, pk, *args, **kwargs):
FILE: demo/formsbased/polls/admin.py
class ChoiceInline (line 6) | class ChoiceInline(admin.TabularInline):
class QuestionAdmin (line 11) | class QuestionAdmin(admin.ModelAdmin):
FILE: demo/formsbased/polls/api/filters.py
class QuestionFilter (line 5) | class QuestionFilter(django_filters.FilterSet):
class Meta (line 6) | class Meta:
class ChoiceFilter (line 11) | class ChoiceFilter(django_filters.FilterSet):
class Meta (line 12) | class Meta:
FILE: demo/formsbased/polls/api/serializers.py
class QuestionSerializer (line 5) | class QuestionSerializer(serializers.ModelSerializer):
class Meta (line 6) | class Meta:
class ChoiceSerializer (line 11) | class ChoiceSerializer(serializers.ModelSerializer):
class Meta (line 14) | class Meta:
FILE: demo/formsbased/polls/api/views.py
class QuestionViewSet (line 11) | class QuestionViewSet(ModelViewSet):
class ChoiceViewSet (line 17) | class ChoiceViewSet(ModelViewSet):
method vote (line 23) | def vote(self, request, pk=None):
FILE: demo/formsbased/polls/apps.py
class PollsConfig (line 4) | class PollsConfig(AppConfig):
FILE: demo/formsbased/polls/migrations/0001_initial.py
class Migration (line 7) | class Migration(migrations.Migration):
FILE: demo/formsbased/polls/models.py
class Question (line 7) | class Question(models.Model):
method __str__ (line 11) | def __str__(self):
method was_published_recently (line 14) | def was_published_recently(self):
class Choice (line 22) | class Choice(models.Model):
method __str__ (line 27) | def __str__(self):
method vote (line 30) | def vote(self):
FILE: demo/formsbased/polls/views.py
class IndexView (line 11) | class IndexView(generic.ListView):
method get_queryset (line 15) | def get_queryset(self):
class DetailView (line 25) | class DetailView(generic.DetailView):
method get_queryset (line 29) | def get_queryset(self):
class VoteView (line 36) | class VoteView(LoginRequiredMixin, generic.DetailView):
method get_queryset (line 40) | def get_queryset(self):
method post (line 46) | def post(self, request, pk, *args, **kwargs):
FILE: django_auth_adfs/backend.py
class AdfsBaseBackend (line 17) | class AdfsBaseBackend(ModelBackend):
method _ms_request (line 19) | def _ms_request(self, action, url, data=None, **kwargs):
method exchange_auth_code (line 47) | def exchange_auth_code(self, authorization_code, request):
method get_obo_access_token (line 63) | def get_obo_access_token(self, access_token):
method get_group_memberships_from_ms_graph_params (line 91) | def get_group_memberships_from_ms_graph_params(self):
method get_group_memberships_from_ms_graph (line 110) | def get_group_memberships_from_ms_graph(self, obo_access_token):
method validate_access_token (line 142) | def validate_access_token(self, access_token):
method process_access_token (line 184) | def process_access_token(self, access_token, adfs_response=None):
method process_user_groups (line 217) | def process_user_groups(self, claims, access_token):
method create_user (line 252) | def create_user(self, claims):
method update_user_attributes (line 298) | def update_user_attributes(self, user, claims, claim_mapping=None):
method update_user_groups (line 337) | def update_user_groups(self, user, claim_groups):
method update_user_flags (line 369) | def update_user_flags(self, user, claims, claim_groups):
class AdfsAuthCodeBackend (line 406) | class AdfsAuthCodeBackend(AdfsBaseBackend):
method authenticate (line 412) | def authenticate(self, request=None, authorization_code=None, **kwargs):
class AdfsAccessTokenBackend (line 427) | class AdfsAccessTokenBackend(AdfsBaseBackend):
method authenticate (line 433) | def authenticate(self, request=None, access_token=None, **kwargs):
class AdfsBackend (line 447) | class AdfsBackend(AdfsAuthCodeBackend):
FILE: django_auth_adfs/config.py
class ConfigLoadError (line 31) | class ConfigLoadError(Exception):
function _get_settings_class (line 35) | def _get_settings_class():
class Settings (line 46) | class Settings(object):
method __init__ (line 51) | def __init__(self):
class ProviderConfig (line 178) | class ProviderConfig(object):
method __init__ (line 179) | def __init__(self):
method load_config (line 208) | def load_config(self):
method _load_openid_config (line 240) | def _load_openid_config(self):
method _load_federation_metadata (line 280) | def _load_federation_metadata(self):
method _load_keys (line 314) | def _load_keys(self, certificates):
method redirect_uri (line 322) | def redirect_uri(self, request):
method build_authorization_endpoint (line 326) | def build_authorization_endpoint(self, request, disable_sso=None, forc...
method build_end_session_endpoint (line 371) | def build_end_session_endpoint(self):
FILE: django_auth_adfs/exceptions.py
class MFARequired (line 1) | class MFARequired(Exception):
FILE: django_auth_adfs/middleware.py
class LoginRequiredMiddleware (line 23) | class LoginRequiredMiddleware:
method __init__ (line 33) | def __init__(self, get_response):
method __call__ (line 36) | def __call__(self, request):
FILE: django_auth_adfs/rest_framework.py
class AdfsAccessTokenAuthentication (line 12) | class AdfsAccessTokenAuthentication(BaseAuthentication):
method authenticate (line 18) | def authenticate(self, request):
method authenticate_header (line 51) | def authenticate_header(self, request):
FILE: django_auth_adfs/views.py
class OAuth2CallbackView (line 20) | class OAuth2CallbackView(View):
method get (line 21) | def get(self, request):
class OAuth2LoginView (line 77) | class OAuth2LoginView(View):
method get (line 78) | def get(self, request):
method post (line 87) | def post(self, request):
class OAuth2LoginNoSSOView (line 97) | class OAuth2LoginNoSSOView(View):
method get (line 98) | def get(self, request):
method post (line 107) | def post(self, request):
class OAuth2LoginForceMFA (line 117) | class OAuth2LoginForceMFA(View):
method get (line 118) | def get(self, request):
method post (line 127) | def post(self, request):
class OAuth2LogoutView (line 137) | class OAuth2LogoutView(View):
method get (line 138) | def get(self, request):
method post (line 148) | def post(self, request):
FILE: tests/custom_config.py
class Settings (line 1) | class Settings(object):
method __init__ (line 5) | def __init__(self):
FILE: tests/models.py
class Profile (line 5) | class Profile(models.Model):
FILE: tests/test_authentication.py
class AuthenticationTests (line 26) | class AuthenticationTests(TestCase):
method setUp (line 27) | def setUp(self):
method test_post_authenticate_signal_send (line 36) | def test_post_authenticate_signal_send(self):
method test_with_auth_code_2012 (line 42) | def test_with_auth_code_2012(self):
method test_with_auth_code_2016 (line 54) | def test_with_auth_code_2016(self):
method test_mfa_error_backends (line 66) | def test_mfa_error_backends(self):
method test_with_auth_code_azure (line 72) | def test_with_auth_code_azure(self):
method test_with_auth_code_azure_guest_block (line 91) | def test_with_auth_code_azure_guest_block(self):
method test_with_auth_code_azure_guest_no_block (line 108) | def test_with_auth_code_azure_guest_no_block(self):
method test_version_two_endpoint_calls_correct_url (line 131) | def test_version_two_endpoint_calls_correct_url(self):
method test_empty (line 153) | def test_empty(self):
method test_group_claim (line 158) | def test_group_claim(self):
method test_no_group_claim (line 169) | def test_no_group_claim(self):
method test_group_claim_with_mirror_groups (line 180) | def test_group_claim_with_mirror_groups(self):
method test_group_claim_without_mirror_groups (line 196) | def test_group_claim_without_mirror_groups(self):
method test_empty_keys (line 212) | def test_empty_keys(self):
method test_group_removal (line 218) | def test_group_removal(self):
method test_group_removal_overlap (line 242) | def test_group_removal_overlap(self):
method test_group_to_flag_mapping (line 268) | def test_group_to_flag_mapping(self):
method test_boolean_claim_mapping (line 287) | def test_boolean_claim_mapping(self):
method test_extended_model_claim_mapping_missing_instance (line 304) | def test_extended_model_claim_mapping_missing_instance(self):
method test_extended_model_claim_mapping (line 326) | def test_extended_model_claim_mapping(self):
method test_authentication (line 354) | def test_authentication(self):
method test_mfa_error (line 360) | def test_mfa_error(self):
method test_callback_redir (line 374) | def test_callback_redir(self):
method test_missing_code (line 381) | def test_missing_code(self):
method test_login_redir (line 386) | def test_login_redir(self):
method test_oauth_redir_2012 (line 392) | def test_oauth_redir_2012(self):
method test_oauth_redir_2016 (line 410) | def test_oauth_redir_2016(self):
method test_oauth_redir_azure_version_one (line 429) | def test_oauth_redir_azure_version_one(self):
method test_oauth_redir_azure_version_two (line 455) | def test_oauth_redir_azure_version_two(self):
method test_scopes_generated_correctly (line 481) | def test_scopes_generated_correctly(self):
method test_inactive_user (line 508) | def test_inactive_user(self):
method test_nonexisting_user (line 518) | def test_nonexisting_user(self):
FILE: tests/test_drf_integration.py
class RestFrameworkIntegrationTests (line 18) | class RestFrameworkIntegrationTests(TestCase):
method setUp (line 19) | def setUp(self):
method test_access_token_2012 (line 48) | def test_access_token_2012(self):
method test_access_token_2016 (line 57) | def test_access_token_2016(self):
method test_access_token_azure (line 66) | def test_access_token_azure(self):
method test_access_token_azure_guest (line 81) | def test_access_token_azure_guest(self):
method test_access_token_azure_no_guest (line 98) | def test_access_token_azure_no_guest(self):
method test_access_token_azure_guest_but_no_upn (line 115) | def test_access_token_azure_guest_but_no_upn(self):
method test_access_token_azure_guest_with_idp (line 132) | def test_access_token_azure_guest_with_idp(self):
method test_access_token_azure_guest_but_no_upn_but_no_guest_username_claim (line 149) | def test_access_token_azure_guest_but_no_upn_but_no_guest_username_cla...
method test_process_group_claim_from_ms_graph (line 166) | def test_process_group_claim_from_ms_graph(self):
method test_get_obo_access_token_mfa_error (line 184) | def test_get_obo_access_token_mfa_error(self):
method test_get_obo_access_token_version_2 (line 200) | def test_get_obo_access_token_version_2(self):
method test_missing_ms_graph_group_permission (line 219) | def test_missing_ms_graph_group_permission(self):
method test_access_token_exceptions (line 235) | def test_access_token_exceptions(self):
FILE: tests/test_settings.py
class SettingsTests (line 13) | class SettingsTests(TestCase):
method test_no_settings (line 14) | def test_no_settings(self):
method test_claim_mapping_overlapping_username_field (line 21) | def test_claim_mapping_overlapping_username_field(self):
method test_tenant_and_server (line 28) | def test_tenant_and_server(self):
method test_no_tenant_but_block_guest (line 36) | def test_no_tenant_but_block_guest(self):
method test_tenant_with_block_users (line 44) | def test_tenant_with_block_users(self):
method test_unknown_setting (line 53) | def test_unknown_setting(self):
method test_required_setting (line 60) | def test_required_setting(self):
method test_default_failed_response_setting (line 67) | def test_default_failed_response_setting(self):
method test_dotted_path_failed_response_setting (line 73) | def test_dotted_path_failed_response_setting(self):
method test_settings_version (line 80) | def test_settings_version(self):
method test_not_azure_but_version_is_set (line 91) | def test_not_azure_but_version_is_set(self):
method test_configured_proxy (line 99) | def test_configured_proxy(self):
method test_no_configured_proxy (line 106) | def test_no_configured_proxy(self):
class CustomSettingsTests (line 111) | class CustomSettingsTests(SimpleTestCase):
method setUp (line 112) | def setUp(self):
method tearDown (line 115) | def tearDown(self):
method test_dotted_path (line 118) | def test_dotted_path(self):
FILE: tests/utils.py
function generate_key_and_cert (line 19) | def generate_key_and_cert():
class SimpleUtc (line 52) | class SimpleUtc(tzinfo):
method tzname (line 53) | def tzname(self, dt):
method utcoffset (line 56) | def utcoffset(self, dt):
function load_json (line 60) | def load_json(file):
function build_access_token_adfs (line 66) | def build_access_token_adfs(request):
function build_access_token_azure (line 71) | def build_access_token_azure(request):
function build_access_token_azure_not_guest (line 76) | def build_access_token_azure_not_guest(request):
function build_access_token_azure_guest (line 81) | def build_access_token_azure_guest(request):
function build_access_token_azure_guest_no_upn (line 86) | def build_access_token_azure_guest_no_upn(request):
function build_access_token_azure_guest_with_idp (line 91) | def build_access_token_azure_guest_with_idp(request):
function build_access_token_azure_groups_in_claim_source (line 96) | def build_access_token_azure_groups_in_claim_source(request):
function do_build_mfa_error (line 101) | def do_build_mfa_error(request):
function do_build_graph_response (line 106) | def do_build_graph_response(request):
function do_build_graph_response_no_group_perm (line 110) | def do_build_graph_response_no_group_perm(request):
function do_build_access_token (line 114) | def do_build_access_token(request, issuer, schema=None, no_upn=False, id...
function do_build_obo_access_token (line 174) | def do_build_obo_access_token(request):
function do_build_ms_graph_groups (line 234) | def do_build_ms_graph_groups(request, missing_group_names=False):
function build_openid_keys (line 314) | def build_openid_keys(request, empty_keys=False):
function build_adfs_meta (line 343) | def build_adfs_meta(request):
function mock_adfs (line 351) | def mock_adfs(
FILE: tests/views.py
function test_failed_response (line 1) | def test_failed_response(request, error_message, status):
Condensed preview — 119 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (420K chars).
[
{
"path": ".codecov.yml",
"chars": 279,
"preview": "# Docs: https://docs.codecov.io/docs/codecovyml-reference\n\ncodecov:\n require_ci_to_pass: yes\n\ncoverage:\n precision: 1\n"
},
{
"path": ".coveragerc",
"chars": 32,
"preview": "[run]\nsource = django_auth_adfs\n"
},
{
"path": ".editorconfig",
"chars": 701,
"preview": "# http://editorconfig.org\n\nroot = true\n\n[*]\nindent_style = space\nindent_size = 4\ninsert_final_newline = true\ntrim_traili"
},
{
"path": ".github/FUNDING.yml",
"chars": 35,
"preview": "github: [jobec, jonasks, sondrelg]\n"
},
{
"path": ".github/workflows/codecov.yml",
"chars": 1292,
"preview": "name: coverage\n\non:\n pull_request:\n push:\n branches:\n - main\n\njobs:\n codecov:\n # -------------------------"
},
{
"path": ".github/workflows/publish_to_pypi.yml",
"chars": 532,
"preview": "name: Publish django-auth-adfs to PyPI 📦\n\non:\n release:\n types: [published]\n\njobs:\n build-and-publish:\n name: Bu"
},
{
"path": ".github/workflows/testing.yml",
"chars": 2441,
"preview": "name: test\n\non:\n pull_request:\n push:\n branches:\n - main\n\njobs:\n linting:\n runs-on: ubuntu-latest\n step"
},
{
"path": ".gitignore",
"chars": 1050,
"preview": "# based on:\n# https://github.com/github/gitignore/blob/master/Python.gitignore\n# https://www.jetbrains.com/pycharm/help/"
},
{
"path": ".readthedocs.yaml",
"chars": 551,
"preview": "# Read the Docs configuration file\n# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details\n\n# Requir"
},
{
"path": "CONTRIBUTING.rst",
"chars": 1804,
"preview": "============\nContributing\n============\n\nContributions are welcome, and they are greatly appreciated! Every little bit he"
},
{
"path": "LICENSE",
"chars": 1277,
"preview": "Copyright (c) 2016, Joris Beckers\n\nRedistribution and use in source and binary forms, with or without modification,\nare "
},
{
"path": "MANIFEST.in",
"chars": 309,
"preview": "include CONTRIBUTING.rst\ninclude README.rst\n\nrecursive-include tests *\nrecursive-exclude * __pycache__\nrecursive-exclude"
},
{
"path": "README.rst",
"chars": 4950,
"preview": "ADFS Authentication for Django\n==============================\n\n.. image:: https://readthedocs.org/projects/django-auth-a"
},
{
"path": "Vagrantfile",
"chars": 2565,
"preview": "dir = File.expand_path(\"..\", __FILE__)\n\nVagrant.configure(\"2\") do |config|\n config.vagrant.plugins = \"vagrant-reload\"\n\n"
},
{
"path": "demo/adfs/manage.py",
"chars": 538,
"preview": "#!/usr/bin/env python\nimport os\nimport sys\n\nif __name__ == \"__main__\":\n os.environ.setdefault(\"DJANGO_SETTINGS_MODULE"
},
{
"path": "demo/adfs/mysite/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "demo/adfs/mysite/settings.py",
"chars": 5048,
"preview": "\"\"\"\nDjango settings for mysite project.\n\nGenerated by 'django-admin startproject' using Django 2.0.5.\n\nFor more informat"
},
{
"path": "demo/adfs/mysite/urls.py",
"chars": 1646,
"preview": "\"\"\"mysite URL Configuration\n\nThe `urlpatterns` list routes URLs to views. For more information please see:\n https://d"
},
{
"path": "demo/adfs/mysite/wsgi.py",
"chars": 389,
"preview": "\"\"\"\nWSGI config for mysite project.\n\nIt exposes the WSGI callable as a module-level variable named ``application``.\n\nFor"
},
{
"path": "demo/adfs/polls/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "demo/adfs/polls/admin.py",
"chars": 574,
"preview": "from django.contrib import admin\n\nfrom .models import Choice, Question\n\n\nclass ChoiceInline(admin.TabularInline):\n mo"
},
{
"path": "demo/adfs/polls/api/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "demo/adfs/polls/api/filters.py",
"chars": 339,
"preview": "import django_filters\nfrom ..models import Choice, Question\n\n\nclass QuestionFilter(django_filters.FilterSet):\n class "
},
{
"path": "demo/adfs/polls/api/serializers.py",
"chars": 446,
"preview": "from ..models import Choice, Question\nimport rest_framework.serializers as serializers\n\n\nclass QuestionSerializer(serial"
},
{
"path": "demo/adfs/polls/api/urls.py",
"chars": 244,
"preview": "from rest_framework import routers\n\nfrom . import views\n\n\nrouter = routers.DefaultRouter()\n\nrouter.register(r'questions'"
},
{
"path": "demo/adfs/polls/api/views.py",
"chars": 1011,
"preview": "from rest_framework.viewsets import ModelViewSet\nfrom rest_framework.decorators import action\nfrom rest_framework.permis"
},
{
"path": "demo/adfs/polls/apps.py",
"chars": 85,
"preview": "from django.apps import AppConfig\n\n\nclass PollsConfig(AppConfig):\n name = 'polls'\n"
},
{
"path": "demo/adfs/polls/migrations/0001_initial.py",
"chars": 1094,
"preview": "# Generated by Django 2.2.6 on 2019-11-01 17:08\n\nfrom django.db import migrations, models\nimport django.db.models.deleti"
},
{
"path": "demo/adfs/polls/migrations/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "demo/adfs/polls/models.py",
"chars": 1053,
"preview": "import datetime\n\nfrom django.db import models\nfrom django.utils import timezone\n\n\nclass Question(models.Model):\n ques"
},
{
"path": "demo/adfs/polls/templates/admin/base_site.html",
"chars": 284,
"preview": "{% extends \"admin/base.html\" %}\n\n{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblo"
},
{
"path": "demo/adfs/polls/templates/polls/detail.html",
"chars": 623,
"preview": "{% extends \"base.html\" %}\n{% block content %}\n<h3>{{ question.question_text }}</h3>\n<div class=\"row\">\n <div class=\"col\""
},
{
"path": "demo/adfs/polls/templates/polls/index.html",
"chars": 421,
"preview": "{% extends \"base.html\" %}\n{% block content %}\n <h3>Available polls</h3>\n {% if latest_question_list %}\n <div class="
},
{
"path": "demo/adfs/polls/templates/polls/vote.html",
"chars": 569,
"preview": "{% extends \"base.html\" %}\n{% block content %}\n<h3>{{ question.question_text }}</h3>\n\n{% if error_message %}<p><strong>{{"
},
{
"path": "demo/adfs/polls/urls.py",
"chars": 343,
"preview": "from django.urls import path\n\nfrom . import views\n\napp_name = 'polls'\nurlpatterns = [\n path('', views.IndexView.as_vi"
},
{
"path": "demo/adfs/polls/views.py",
"chars": 2085,
"preview": "from django.contrib.auth.mixins import LoginRequiredMixin\nfrom django.http import HttpResponseRedirect\nfrom django.short"
},
{
"path": "demo/adfs/templates/base.html",
"chars": 2322,
"preview": "{% load static %}\n<!doctype html>\n<html lang=\"en\">\n <head>\n <!-- Required meta tags -->\n <meta charset=\"utf-8\">\n "
},
{
"path": "demo/adfs/templates/home.html",
"chars": 294,
"preview": "{% extends 'base.html' %}\n\n{% block title %}Home{% endblock %}\n\n{% block content %}\n <div class=\"jumbotron jumbotron-fl"
},
{
"path": "demo/formsbased/manage.py",
"chars": 538,
"preview": "#!/usr/bin/env python\nimport os\nimport sys\n\nif __name__ == \"__main__\":\n os.environ.setdefault(\"DJANGO_SETTINGS_MODULE"
},
{
"path": "demo/formsbased/mysite/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "demo/formsbased/mysite/settings.py",
"chars": 3475,
"preview": "\"\"\"\nDjango settings for mysite project.\n\nGenerated by 'django-admin startproject' using Django 2.0.5.\n\nFor more informat"
},
{
"path": "demo/formsbased/mysite/urls.py",
"chars": 1389,
"preview": "\"\"\"mysite URL Configuration\n\nThe `urlpatterns` list routes URLs to views. For more information please see:\n https://d"
},
{
"path": "demo/formsbased/mysite/wsgi.py",
"chars": 389,
"preview": "\"\"\"\nWSGI config for mysite project.\n\nIt exposes the WSGI callable as a module-level variable named ``application``.\n\nFor"
},
{
"path": "demo/formsbased/polls/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "demo/formsbased/polls/admin.py",
"chars": 574,
"preview": "from django.contrib import admin\n\nfrom .models import Choice, Question\n\n\nclass ChoiceInline(admin.TabularInline):\n mo"
},
{
"path": "demo/formsbased/polls/api/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "demo/formsbased/polls/api/filters.py",
"chars": 339,
"preview": "import django_filters\nfrom ..models import Choice, Question\n\n\nclass QuestionFilter(django_filters.FilterSet):\n class "
},
{
"path": "demo/formsbased/polls/api/serializers.py",
"chars": 446,
"preview": "from ..models import Choice, Question\nimport rest_framework.serializers as serializers\n\n\nclass QuestionSerializer(serial"
},
{
"path": "demo/formsbased/polls/api/urls.py",
"chars": 244,
"preview": "from rest_framework import routers\n\nfrom . import views\n\n\nrouter = routers.DefaultRouter()\n\nrouter.register(r'questions'"
},
{
"path": "demo/formsbased/polls/api/views.py",
"chars": 1011,
"preview": "from rest_framework.viewsets import ModelViewSet\nfrom rest_framework.decorators import action\nfrom rest_framework.permis"
},
{
"path": "demo/formsbased/polls/apps.py",
"chars": 85,
"preview": "from django.apps import AppConfig\n\n\nclass PollsConfig(AppConfig):\n name = 'polls'\n"
},
{
"path": "demo/formsbased/polls/migrations/0001_initial.py",
"chars": 1094,
"preview": "# Generated by Django 2.2.6 on 2019-11-01 17:08\n\nfrom django.db import migrations, models\nimport django.db.models.deleti"
},
{
"path": "demo/formsbased/polls/migrations/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "demo/formsbased/polls/models.py",
"chars": 1053,
"preview": "import datetime\n\nfrom django.db import models\nfrom django.utils import timezone\n\n\nclass Question(models.Model):\n ques"
},
{
"path": "demo/formsbased/polls/templates/admin/base_site.html",
"chars": 284,
"preview": "{% extends \"admin/base.html\" %}\n\n{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblo"
},
{
"path": "demo/formsbased/polls/templates/polls/detail.html",
"chars": 623,
"preview": "{% extends \"base.html\" %}\n{% block content %}\n<h3>{{ question.question_text }}</h3>\n<div class=\"row\">\n <div class=\"col\""
},
{
"path": "demo/formsbased/polls/templates/polls/index.html",
"chars": 421,
"preview": "{% extends \"base.html\" %}\n{% block content %}\n <h3>Available polls</h3>\n {% if latest_question_list %}\n <div class="
},
{
"path": "demo/formsbased/polls/templates/polls/vote.html",
"chars": 569,
"preview": "{% extends \"base.html\" %}\n{% block content %}\n<h3>{{ question.question_text }}</h3>\n\n{% if error_message %}<p><strong>{{"
},
{
"path": "demo/formsbased/polls/urls.py",
"chars": 343,
"preview": "from django.urls import path\n\nfrom . import views\n\napp_name = 'polls'\nurlpatterns = [\n path('', views.IndexView.as_vi"
},
{
"path": "demo/formsbased/polls/views.py",
"chars": 2085,
"preview": "from django.contrib.auth.mixins import LoginRequiredMixin\nfrom django.http import HttpResponseRedirect\nfrom django.short"
},
{
"path": "demo/formsbased/templates/base.html",
"chars": 2136,
"preview": "{% load static %}\n<!doctype html>\n<html lang=\"en\">\n <head>\n <!-- Required meta tags -->\n <meta charset=\"utf-8\">\n "
},
{
"path": "demo/formsbased/templates/home.html",
"chars": 294,
"preview": "{% extends 'base.html' %}\n\n{% block title %}Home{% endblock %}\n\n{% block content %}\n <div class=\"jumbotron jumbotron-fl"
},
{
"path": "demo/formsbased/templates/registration/logged_out.html",
"chars": 228,
"preview": "{% extends 'base.html' %}\n\n{% block title %}See you!{% endblock %}\n\n{% block content %}\n <h2>Logged out</h2>\n <p>You h"
},
{
"path": "demo/formsbased/templates/registration/login.html",
"chars": 1102,
"preview": "{% extends \"base.html\" %}\n\n{% block content %}\n<h3>Login</h3>\n{% if form.errors %}\n<p>Your username and password didn't "
},
{
"path": "django_auth_adfs/__init__.py",
"chars": 137,
"preview": "\"\"\"\nDon't put imports or code here\nThis file is imported by setup.py\nAdding imports here will break setup.py\n\"\"\"\n\n__vers"
},
{
"path": "django_auth_adfs/backend.py",
"chars": 18442,
"preview": "import logging\n\nimport jwt\nfrom django.contrib.auth import get_user_model\nfrom django.contrib.auth.backends import Model"
},
{
"path": "django_auth_adfs/config.py",
"chars": 16376,
"preview": "import base64\nimport logging\nimport warnings\nfrom datetime import datetime, timedelta\nfrom xml.etree import ElementTree\n"
},
{
"path": "django_auth_adfs/drf-urls.py",
"chars": 221,
"preview": "# flake8: noqa\nimport warnings\nfrom .drf_urls import *\n\nwarnings.warn(\n \"drf-urls.py is not a valid module name and w"
},
{
"path": "django_auth_adfs/drf_urls.py",
"chars": 430,
"preview": "\"\"\"\nThese URL patterns are used to override the default Django Rest Framework login page.\n\nIt's a bit of a hack, but DRF"
},
{
"path": "django_auth_adfs/exceptions.py",
"chars": 110,
"preview": "class MFARequired(Exception):\n \"\"\"\n Exception to indicate that a MFA auth is required.\n \"\"\"\n pass\n"
},
{
"path": "django_auth_adfs/middleware.py",
"chars": 2249,
"preview": "\"\"\"\nBased on https://djangosnippets.org/snippets/1179/\n\"\"\"\nfrom re import compile\n\nfrom django.conf import settings as d"
},
{
"path": "django_auth_adfs/rest_framework.py",
"chars": 1841,
"preview": "from __future__ import absolute_import\n\nfrom django.contrib.auth import authenticate\nfrom rest_framework import exceptio"
},
{
"path": "django_auth_adfs/signals.py",
"chars": 137,
"preview": "from django.dispatch import Signal\n\n# Arguments sent with the signal:\n# * user\n# * claims\n# * adfs_response\npost_authent"
},
{
"path": "django_auth_adfs/templates/django_auth_adfs/login_failed.html",
"chars": 652,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <title>Login Failure</title>\n <style>\n #message {"
},
{
"path": "django_auth_adfs/urls.py",
"chars": 533,
"preview": "from django.urls import re_path\n\nfrom django_auth_adfs import views\n\napp_name = \"django_auth_adfs\"\n\nurlpatterns = [\n "
},
{
"path": "django_auth_adfs/views.py",
"chars": 5319,
"preview": "import base64\nimport logging\n\nfrom django.conf import settings as django_settings\nfrom django.contrib.auth import authen"
},
{
"path": "docs/Makefile",
"chars": 7696,
"preview": "# Makefile for Sphinx documentation\n#\n\n# You can set these variables from the command line.\nSPHINXOPTS =\nSPHINXBUILD "
},
{
"path": "docs/_templates/.gitkeep",
"chars": 0,
"preview": ""
},
{
"path": "docs/adfs_3.0_config_guide.rst",
"chars": 8781,
"preview": "Windows 2012 R2 - ADFS 3.0\n==========================\n\nGetting this module to work is sometimes not so straight forward."
},
{
"path": "docs/adfs_4.0_config_guide.rst",
"chars": 6879,
"preview": "Windows 2016 - ADFS 4.0\n=======================\n\nGetting this module to work is sometimes not so straight forward. If yo"
},
{
"path": "docs/azure_ad_config_guide.rst",
"chars": 6637,
"preview": "Azure AD\n========\n\nGetting this module to work is sometimes not so straightforward. If you're not familiar with JWT toke"
},
{
"path": "docs/conf.py",
"chars": 9579,
"preview": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n#\n# django_auth_adfs documentation build configuration file, created by\n#"
},
{
"path": "docs/config_guides.rst",
"chars": 144,
"preview": "ADFS Config Guides\n==================\n\n.. toctree::\n :maxdepth: 3\n\n adfs_3.0_config_guide\n adfs_4.0_config_guide\n "
},
{
"path": "docs/contributing.rst",
"chars": 33,
"preview": ".. include:: ../CONTRIBUTING.rst\n"
},
{
"path": "docs/demo.rst",
"chars": 4569,
"preview": "Demo\n====\nA ``Vagrantfile`` and example project are available to show what's needed to convert a Django project from for"
},
{
"path": "docs/faq.rst",
"chars": 3545,
"preview": "Frequently Asked Questions\n==========================\n\nWhy am I always redirected to ``/accounts/profile/`` after login?"
},
{
"path": "docs/index.rst",
"chars": 1615,
"preview": "ADFS Authentication for Django\n==============================\n\n.. image:: https://readthedocs.org/projects/django-auth-a"
},
{
"path": "docs/install.rst",
"chars": 4566,
"preview": ".. _install:\n\nInstallation\n============\n\nRequirements\n------------\n\n* Python 3.9 and above\n* Django 4.2 and above\n\nYou w"
},
{
"path": "docs/make.bat",
"chars": 7006,
"preview": "@ECHO OFF\n\nREM Command file for Sphinx documentation\n\nif \"%SPHINXBUILD%\" == \"\" (\n\tset SPHINXBUILD=sphinx-build\n)\nset BUI"
},
{
"path": "docs/middleware.rst",
"chars": 1098,
"preview": "Login Middleware\n================\n\n**django-auth-adfs** ships with a middleware class named ``LoginRequiredMiddleware``."
},
{
"path": "docs/oauth2_explained.rst",
"chars": 17673,
"preview": "OAuth2 and ADFS explained\n=========================\n\nThis chapter tries to explain how ADFS implements the OAuth2 and Op"
},
{
"path": "docs/requirements.txt",
"chars": 17,
"preview": "sphinx_rtd_theme\n"
},
{
"path": "docs/rest_framework.rst",
"chars": 5442,
"preview": "Rest Framework integration\n==========================\n\nSetup\n-----\n\nWhen using Django Rest Framework, you can also use t"
},
{
"path": "docs/settings_ref.rst",
"chars": 16463,
"preview": ".. _settings:\n\nSettings Reference\n==================\n\n.. _audience_setting:\n\nAUDIENCE\n--------\n* **Default**:\n* **Type**"
},
{
"path": "docs/signals.rst",
"chars": 1578,
"preview": "Django Signals\n================\n\n**django-auth-adfs** uses Django `Signals <https://docs.djangoproject.com/en/stable/top"
},
{
"path": "docs/troubleshooting.rst",
"chars": 2117,
"preview": "Troubleshooting\n===============\n\nTurn on Django debug logging\n----------------------------\nIf you run into any problems,"
},
{
"path": "manage.py",
"chars": 248,
"preview": "#!/usr/bin/env python\nimport os\nimport sys\n\nif __name__ == \"__main__\":\n os.environ.setdefault(\"DJANGO_SETTINGS_MODULE"
},
{
"path": "pyproject.toml",
"chars": 2131,
"preview": "[tool.poetry]\nname = 'django-auth-adfs'\nversion = \"1.16.0\" # Remember to also change __init__.py version\ndescription = "
},
{
"path": "setup.cfg",
"chars": 62,
"preview": "[flake8]\nmax-line-length = 120\nexclude =\n docs/*\n .venv\n"
},
{
"path": "tests/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "tests/custom_config.py",
"chars": 125,
"preview": "class Settings(object):\n RETRIES = 1\n CA_BUNDLE = False\n\n def __init__(self):\n self.SERVER = 'custom-ser"
},
{
"path": "tests/mock_files/FederationMetadata.xml",
"chars": 80348,
"preview": "<EntityDescriptor ID=\"_7bfbd44d-3197-434f-b1de-388d1ead1308\" entityID=\"http://adfs.example.com/adfs/services/trust\"\n "
},
{
"path": "tests/mock_files/adfs-openid-configuration.json",
"chars": 2058,
"preview": "{\n \"issuer\": \"https://adfs.example.com/adfs\",\n \"authorization_endpoint\": \"https://adfs.example.com/adfs/oauth2/authori"
},
{
"path": "tests/mock_files/azure-openid-configuration-v2.json",
"chars": 1982,
"preview": "{\n \"authorization_endpoint\": \"https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/v2.0/auth"
},
{
"path": "tests/mock_files/azure-openid-configuration.json",
"chars": 1840,
"preview": "{\n \"authorization_endpoint\": \"https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/authorize\","
},
{
"path": "tests/models.py",
"chars": 253,
"preview": "from django.db import models\nfrom django.contrib.auth.models import User\n\n\nclass Profile(models.Model):\n user = model"
},
{
"path": "tests/settings.py",
"chars": 2308,
"preview": "SECRET_KEY = 'secret'\n\nDATABASES = {\n 'default': {\n 'ENGINE': 'django.db.backends.sqlite3',\n 'NAME': ':"
},
{
"path": "tests/test_authentication.py",
"chars": 24913,
"preview": "import base64\n\nfrom django_auth_adfs.exceptions import MFARequired\n\ntry:\n from urllib.parse import parse_qs, urlparse"
},
{
"path": "tests/test_drf_integration.py",
"chars": 13893,
"preview": "import json\nfrom copy import deepcopy\n\nfrom django.test import RequestFactory, TestCase\nfrom mock import patch\nfrom rest"
},
{
"path": "tests/test_settings.py",
"chars": 5156,
"preview": "import sys\nfrom copy import deepcopy\n\nfrom django.core.exceptions import ImproperlyConfigured\nfrom django.test import Te"
},
{
"path": "tests/urls.py",
"chars": 184,
"preview": "from django.urls import include, re_path\n\nurlpatterns = [\n re_path(r'^oauth2/', include('django_auth_adfs.urls')),\n "
},
{
"path": "tests/utils.py",
"chars": 18822,
"preview": "import base64\nimport json\nimport os\nimport re\nimport time\nfrom datetime import datetime, tzinfo, timedelta\nfrom functool"
},
{
"path": "tests/views.py",
"chars": 67,
"preview": "def test_failed_response(request, error_message, status):\n pass\n"
},
{
"path": "vagrant/01-setup-domain.ps1",
"chars": 1028,
"preview": "# ########## SETTINGS #########\n$domainName = \"example.com\"\n$netbiosName = \"EXAMPLE\"\n$safeModePwd = \"Password123\"\n# ####"
},
{
"path": "vagrant/02-setup-vagrant-user.ps1",
"chars": 529,
"preview": "Write-Host \"Waiting for domain controller to become reachable.\"\n$isUp = $false\nwhile($isUp -eq $false) {\n Try {\n "
},
{
"path": "vagrant/03-setup-adfs.ps1",
"chars": 2251,
"preview": "# ########## SETTINGS #########\n$adfsHost = \"adfs\"\n# #############################\n\nWrite-Host \"Waiting for domain contr"
},
{
"path": "vagrant/04-example-adfs-config.ps1",
"chars": 3967,
"preview": "# ########## SETTINGS #########\n$webIP = \"10.10.10.10\"\n$webName = \"web\"\n$appName = \"Django Application\"\n$clientId = \"487"
},
{
"path": "vagrant/New-SelfSignedCertificateEx.ps1",
"chars": 26960,
"preview": "#####################################################################\n# New-SelfSignedCertificateEx.ps1\n# Version 1.2\n#"
},
{
"path": "vagrant/README.rst",
"chars": 364,
"preview": "ADFS Setup Scripts for vagrant\n==============================\n\nThis directory contains scripts used by Vagrant while bri"
}
]
About this extraction
This page contains the full source code of the snok/django-auth-adfs GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 119 files (388.0 KB), approximately 100.8k tokens, and a symbol index with 200 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.