├── .gitignore ├── .travis.yml ├── LICENSE ├── README.rst ├── allauth_adfs ├── __init__.py ├── models.py └── socialaccount │ ├── __init__.py │ ├── adapter.py │ └── providers │ ├── __init__.py │ └── adfs_oauth2 │ ├── README │ ├── __init__.py │ ├── compat.py │ ├── provider.py │ ├── tests.py │ ├── urls.py │ ├── utils.py │ └── views.py ├── appveyor.yml ├── setup.py ├── test_project ├── manage.py └── test_project │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── tox.ini └── vagrant ├── Vagrantfile └── provision.sh /.gitignore: -------------------------------------------------------------------------------- 1 | /*.egg-info 2 | /.project 3 | /.pydevproject 4 | /.eggs/ 5 | *.pyc 6 | db.sqlite3 7 | /build 8 | /dist 9 | .coverage 10 | /vagrant/.vagrant 11 | /.tox 12 | /domain.crt 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: generic 2 | addons: 3 | apt: 4 | sources: 5 | - deadsnakes 6 | packages: 7 | - python2.7 8 | - python2.7-dev 9 | - python3.4 10 | - python3.4-dev 11 | - python3.5 12 | - python3.5-dev 13 | - python3.6 14 | - python3.6-dev 15 | - python3.7 16 | - python3.7-dev 17 | env: 18 | - TOXENV=py27-django-111 19 | - TOXENV=py34-django-111 20 | - TOXENV=py34-django-20 21 | - TOXENV=py35-django-111 22 | - TOXENV=py35-django-20 23 | - TOXENV=py35-django-21 24 | - TOXENV=py35-django-22 25 | - TOXENV=py35-django-master 26 | - TOXENV=py36-django-111 27 | - TOXENV=py36-django-20 28 | - TOXENV=py36-django-21 29 | - TOXENV=py36-django-22 30 | - TOXENV=py36-django-master 31 | - TOXENV=py37-django-111 32 | - TOXENV=py37-django-20 33 | - TOXENV=py37-django-21 34 | - TOXENV=py37-django-22 35 | - TOXENV=py37-django-master 36 | matrix: 37 | fast_finish: true 38 | allow_failures: 39 | - env: TOXENV=py34-django-master 40 | - env: TOXENV=py35-django-master 41 | - env: TOXENV=py36-django-master 42 | - env: TOXENV=py37-django-master 43 | install: 44 | - sudo pip install tox 45 | script: 46 | - tox 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 thenewguy and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =================== 2 | django-allauth-adfs 3 | =================== 4 | 5 | .. image:: https://travis-ci.org/thenewguy/django-allauth-adfs.svg?branch=master 6 | :target: https://travis-ci.org/thenewguy/django-allauth-adfs 7 | 8 | .. image:: https://ci.appveyor.com/api/projects/status/hy58o1x9hopfej6k?svg=true 9 | :target: https://ci.appveyor.com/project/thenewguy/django-allauth-adfs 10 | 11 | .. image:: https://coveralls.io/repos/thenewguy/django-allauth-adfs/badge.svg?branch=master 12 | :target: https://coveralls.io/github/thenewguy/django-allauth-adfs?branch=master 13 | 14 | .. image:: https://badge.fury.io/py/django-allauth-adfs.svg 15 | :target: http://badge.fury.io/py/django-allauth-adfs 16 | 17 | ============ 18 | NOTE 19 | ============ 20 | With ADFS 4, the Social App secret must be blank. Earlier versions ignore when the client sends the unused secret key, 21 | but version 4 throws an error even though it isn't used in the auth process. 22 | 23 | ============ 24 | installation 25 | ============ 26 | 27 | apt-get update && apt-get install -y libffi-dev libssl-dev 28 | 29 | pip install django-allauth-adfs django-allauth-adfs[jwt] django-allauth-adfs[pki] 30 | 31 | if you want to enforce staff users to log in via adfs 32 | add allauth_adfs to installed apps and set 33 | SOCIALACCOUNT_ADAPTER = "allauth_adfs.socialaccount.adapter.SocialAccountAdapter" 34 | 35 | if you want to return different django user instances per SocialApp from the provider 36 | use utils.per_social_app_extract_uid_handler instead of the default_extract_uid_handler 37 | this can be useful for permissions handling in multi tenant configurations 38 | and utils.per_social_app_extract_common_fields_handler for the username to be based 39 | on app id. it uses base64 guid and app id. 40 | 41 | if you want the admin to use this auth then you do the following: 42 | AUTHENTICATION_BACKENDS = [ 43 | 'allauth.account.auth_backends.AuthenticationBackend', 44 | ] 45 | 46 | then somewhere in admin.py for an app 47 | 48 | from django.contrib.auth.decorators import login_required 49 | from django.contrib.admin.views.decorators import staff_member_required 50 | from django.contrib import admin 51 | 52 | admin.autodiscover() 53 | 54 | # monkey patch admin login view to redirect to the site login view 55 | admin.site.login = login_required( 56 | staff_member_required(admin.site.login, login_url="permission-denied-change-user") 57 | ) 58 | 59 | the "permission-denied-change-user" view is just a view that presents a message via the messages framework 60 | to the user about why they are being redirected and then redirects to the sign out view. 61 | 62 | ============ 63 | testing 64 | ============ 65 | 66 | cd vagrant/ 67 | vagrant up 68 | vagrant ssh 69 | cd /vagrant/ 70 | 71 | # note we move TOX_WORK_DIR outside of the vagrant synced folder to increase performance 72 | TOX_WORK_DIR=/tmp tox -vv 73 | 74 | -- or test one environment and skip the coverage report -- 75 | 76 | SUPPRESS_COVERAGE_REPORT="--suppress-coverage-report" TOX_WORK_DIR="/tmp" tox -vv -e py36-django-20 77 | 78 | 79 | ============ 80 | create release (windows) 81 | ============ 82 | 83 | ** increment version number since last release ** 84 | 85 | cd path/to/setup.py 86 | 87 | # remove old dist files 88 | del /P dist 89 | 90 | python setup.py sdist bdist_wheel 91 | 92 | twine upload dist/* 93 | -------------------------------------------------------------------------------- /allauth_adfs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thenewguy/django-allauth-adfs/44497a2aa9c76a0fa2b2556594d7796597298284/allauth_adfs/__init__.py -------------------------------------------------------------------------------- /allauth_adfs/models.py: -------------------------------------------------------------------------------- 1 | from allauth.account.signals import user_logged_in 2 | from allauth.socialaccount.adapter import DefaultSocialAccountAdapter, get_adapter 3 | from allauth.socialaccount.providers import registry 4 | from django.contrib import messages 5 | from django.dispatch import receiver 6 | from .socialaccount.providers.adfs_oauth2.provider import ADFSOAuth2Provider 7 | from .socialaccount.adapter import SocialAccountAdapter 8 | 9 | @receiver(user_logged_in) 10 | def ensure_staff_login_via_adfs(**kwargs): 11 | adapter = get_adapter() 12 | if isinstance(adapter, SocialAccountAdapter): 13 | sociallogin = kwargs.get("sociallogin") 14 | via_adfs = sociallogin and sociallogin.account.provider == ADFSOAuth2Provider.id 15 | if not via_adfs: 16 | changed, user = adapter.update_user_fields(kwargs["request"], user=kwargs["user"]) 17 | if changed: 18 | user.save() 19 | provider = registry.by_id(ADFSOAuth2Provider.id) 20 | messages.warning(kwargs["request"], 'User account modified due to log in provider. Log in with the %s provider to restore functionality when needed.' % provider.name) 21 | -------------------------------------------------------------------------------- /allauth_adfs/socialaccount/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thenewguy/django-allauth-adfs/44497a2aa9c76a0fa2b2556594d7796597298284/allauth_adfs/socialaccount/__init__.py -------------------------------------------------------------------------------- /allauth_adfs/socialaccount/adapter.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from allauth.account.signals import user_logged_in 4 | from allauth.exceptions import ImmediateHttpResponse 5 | from allauth.socialaccount.adapter import DefaultSocialAccountAdapter, get_adapter 6 | from allauth.socialaccount.providers import registry 7 | from django.contrib import messages 8 | from django.dispatch import receiver 9 | from django.http import HttpResponseForbidden 10 | from .providers.adfs_oauth2.provider import ADFSOAuth2Provider 11 | 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class SocialAccountAdapter(DefaultSocialAccountAdapter): 17 | def authentication_error(self, 18 | request, 19 | provider_id, 20 | error=None, 21 | exception=None, 22 | extra_context=None): 23 | """ 24 | Invoked when there is an error in the authentication cycle. In this 25 | case, pre_social_login will not be reached. 26 | You can use this hook to intervene, e.g. redirect to an 27 | educational flow by raising an ImmediateHttpResponse. 28 | """ 29 | logger.error("\n\n".join([ 30 | "Error with request: %(request)r", 31 | "For provider: %(provider_id)s", 32 | "Error: %(error)s", 33 | "Exception: %(exception)r", 34 | "Extra context: %(extra_context)s", 35 | ]) % { 36 | "request": request, 37 | "provider_id": provider_id, 38 | "error": error, 39 | "exception": exception, 40 | "extra_context": extra_context, 41 | }) 42 | 43 | def pre_social_login(self, request, sociallogin): 44 | # new user logins are handled by populate_user 45 | if sociallogin.is_existing: 46 | changed, user = self.update_user_fields(request, sociallogin) 47 | if changed: 48 | user.save() 49 | 50 | def populate_user(self, request, sociallogin, data): 51 | user = super(SocialAccountAdapter, self).populate_user(request, sociallogin, data) 52 | self.update_user_fields(request, sociallogin, user) 53 | return user 54 | 55 | def update_user_fields(self, request, sociallogin=None, user=None): 56 | changed = False 57 | if user is None: 58 | user = sociallogin.account.user 59 | adfs_provider = registry.by_id(ADFSOAuth2Provider.id, request) 60 | 61 | false_keys = ["is_staff", "is_superuser"] 62 | boolean_keys = false_keys + ["is_active"] 63 | copy_keys = boolean_keys + ["first_name", "last_name", "email"] 64 | 65 | if sociallogin is not None and sociallogin.account.provider == ADFSOAuth2Provider.id: 66 | data = sociallogin.account.extra_data 67 | values = adfs_provider.extract_common_fields(data) 68 | for key in copy_keys: 69 | # it is assumed that values are cleaned and set for all 70 | # fields and if any of the boolean_keys are not provided 71 | # in the raw data they should be set to False by 72 | # the extract_common_fields method 73 | if getattr(user, key) != values[key]: 74 | setattr(user, key, values[key]) 75 | changed = True 76 | else: 77 | for key in false_keys: 78 | if getattr(user, key): 79 | msg = "Staff users must authenticate via the %s provider!" % adfs_provider.name 80 | response = HttpResponseForbidden(msg) 81 | raise ImmediateHttpResponse(response) 82 | 83 | return changed, user 84 | -------------------------------------------------------------------------------- /allauth_adfs/socialaccount/providers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thenewguy/django-allauth-adfs/44497a2aa9c76a0fa2b2556594d7796597298284/allauth_adfs/socialaccount/providers/__init__.py -------------------------------------------------------------------------------- /allauth_adfs/socialaccount/providers/adfs_oauth2/README: -------------------------------------------------------------------------------- 1 | If you want to verify the jwt returned by ADFS (STRONGLY RECOMMENDED) you must install the following: 2 | ``` 3 | pip install PyJWT 4 | pip install cryptography 5 | ``` 6 | 7 | If you use a private PKI for the ADFS server, you will need to perform the following steps to trust the CA until https://github.com/certifi/python-certifi/issues/22 is resolved: 8 | ``` 9 | pip install certifi 10 | 11 | python -m certifi "where()" 12 | 13 | cat /path/to/trusted.pem >> /path/to/site-packages/certifi/cacert.pem 14 | ``` 15 | 16 | 17 | If you are using Python 2.x, you will need to install the following packages to communicate with ADFS using certificates from your internal certificate authority: 18 | ``` 19 | pip install pyopenssl 20 | pip install ndg-httpsclient 21 | ``` 22 | 23 | 24 | claims used by default: 25 | 26 | ============================= 27 | Send LDAP Attributes as Claim 28 | ============================= 29 | 30 | Outgoing Claim Type: guid 31 | LDAP Attribute: objectGUID 32 | 33 | Outgoing Claim Type: UPN 34 | LDAP Attribute: User-Principal-Name 35 | 36 | Outgoing Claim Type: first_name 37 | LDAP Attribute: Given-Name 38 | 39 | Outgoing Claim Type: last_name 40 | LDAP Attribute: Surname 41 | 42 | Outgoing Claim Type: email 43 | LDAP Attribute: email address or defaults to upn if not provided 44 | 45 | ============================== 46 | Send Group Membership as Claim 47 | ============================== 48 | 49 | name: is_staff 50 | value: "1" or missing 51 | 52 | name: is_superuser 53 | value: "1" or missing 54 | 55 | name: is_active 56 | value: "1" or missing 57 | 58 | ===== 59 | PowerShell to add oauth2 endpoint 60 | ===== 61 | Add-ADFSClient -Name "Foo Client Pretty Name" -ClientId "foo-client-id" -RedirectUri "https://foo.bar.com/accounts/adfs_oauth2/login/callback/" 62 | 63 | ** currently the code assumes client id and resource are not the same. would it be simpler to assume they are and not require the resource config? 64 | 65 | -------------------------------------------------------------------------------- /allauth_adfs/socialaccount/providers/adfs_oauth2/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thenewguy/django-allauth-adfs/44497a2aa9c76a0fa2b2556594d7796597298284/allauth_adfs/socialaccount/providers/adfs_oauth2/__init__.py -------------------------------------------------------------------------------- /allauth_adfs/socialaccount/providers/adfs_oauth2/compat.py: -------------------------------------------------------------------------------- 1 | from django.core.cache import DEFAULT_CACHE_ALIAS 2 | try: 3 | from django.core.cache import caches 4 | except ImportError: 5 | from django.core.cache import get_cache 6 | 7 | class CacheFallback(object): 8 | def __getitem__(self, alias): 9 | return get_cache(alias) 10 | 11 | caches = CacheFallback() -------------------------------------------------------------------------------- /allauth_adfs/socialaccount/providers/adfs_oauth2/provider.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | from allauth.socialaccount import providers 5 | from allauth.socialaccount.adapter import get_adapter 6 | from allauth.socialaccount.providers.base import ProviderAccount 7 | from allauth.socialaccount.providers.oauth2.provider import OAuth2Provider 8 | from .utils import default_extract_extra_data_handler, default_extract_uid_handler, default_extract_common_fields_handler, default_extract_email_addresses_handler 9 | 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class ADFSOAuth2Account(ProviderAccount): 15 | pass 16 | 17 | 18 | def log(key, value): 19 | logger.info('Extracted the following "%s" from this token payload:\n%s', key, value) 20 | 21 | 22 | class ADFSOAuth2Provider(OAuth2Provider): 23 | id = 'adfs_oauth2' 24 | name = 'ADFS Oauth2' 25 | package = 'allauth_adfs.socialaccount.providers.adfs_oauth2' 26 | account_class = ADFSOAuth2Account 27 | 28 | def get_auth_params(self, request, action): 29 | params = super(ADFSOAuth2Provider, self).get_auth_params(request, action) 30 | if "resource" not in params: 31 | raise ImproperlyConfigured("'resource' must be supplied as a key of the AUTH_PARAMS dict under adfs_oauth2 in the SOCIALACCOUNT_PROVIDERS setting.") 32 | return params 33 | 34 | def extract_extra_data(self, data): 35 | app = self.get_app(self.request) 36 | extra_data = self.get_settings().get("extract_extra_data_handler", default_extract_extra_data_handler)(data, app) 37 | log('extra data', extra_data) 38 | return extra_data 39 | 40 | def extract_uid(self, data): 41 | app = self.get_app(self.request) 42 | uid = self.get_settings().get("extract_uid_handler", default_extract_uid_handler)(data, app) 43 | log('uid', uid) 44 | return uid 45 | 46 | def extract_common_fields(self, data): 47 | app = self.get_app(self.request) 48 | common_fields = self.get_settings().get("extract_common_fields_handler", default_extract_common_fields_handler)(data, app) 49 | log('common fields', common_fields) 50 | return common_fields 51 | 52 | def extract_email_addresses(self, data): 53 | app = self.get_app(self.request) 54 | email_addresses = self.get_settings().get("extract_email_addresses_handler", default_extract_email_addresses_handler)(data, app) 55 | # manual string conversion required due to https://github.com/pennersr/django-allauth/issues/2373 56 | log('email addresses', [e.email for e in email_addresses]) 57 | return email_addresses 58 | 59 | provider_classes = [ADFSOAuth2Provider] 60 | -------------------------------------------------------------------------------- /allauth_adfs/socialaccount/providers/adfs_oauth2/tests.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import base64 4 | import json 5 | import socket 6 | import six 7 | import unittest 8 | from xml.parsers.expat import ExpatError 9 | 10 | import requests 11 | from allauth.socialaccount import providers 12 | from allauth.socialaccount.models import SocialApp 13 | from allauth.socialaccount.templatetags.socialaccount import get_providers 14 | from allauth.socialaccount.tests import OAuth2TestsMixin 15 | from allauth.tests import MockedResponse 16 | from django.conf import settings 17 | from django.contrib.sites.models import Site 18 | from django.template import RequestContext, Template 19 | from django.test import TestCase, override_settings 20 | from django.test.client import RequestFactory 21 | from django.urls import reverse 22 | 23 | from .provider import ADFSOAuth2Provider 24 | from .utils import decode_payload_segment, parse_token_payload_segment, default_extract_uid_handler 25 | from .views import ADFSOAuth2Adapter 26 | 27 | 28 | def encode(source): 29 | if six.PY3: 30 | source = source.encode('utf-8') 31 | content = base64.b64encode(source).decode('utf-8') 32 | return content.strip() 33 | 34 | 35 | class TestProviderUrls(TestCase): 36 | def test_urls_importable(self): 37 | from allauth_adfs.socialaccount.providers.adfs_oauth2 import urls 38 | 39 | def test_urls_populated(self): 40 | from allauth_adfs.socialaccount.providers.adfs_oauth2 import urls 41 | self.assertIsInstance(urls.urlpatterns, list) 42 | self.assertTrue(urls.urlpatterns) 43 | 44 | def test_login_url(self): 45 | registry = providers.ProviderRegistry() 46 | registry.load() 47 | provider = registry.by_id(ADFSOAuth2Provider.id) 48 | login_url = provider.get_login_url(request=None) 49 | self.assertEquals(login_url, "/accounts/adfs_oauth2/login/") 50 | 51 | def test_template_login_url(self): 52 | registry = providers.ProviderRegistry() 53 | registry.load() 54 | provider = registry.by_id(ADFSOAuth2Provider.id) 55 | 56 | factory = RequestFactory() 57 | request = factory.get('/accounts/login/') 58 | c = RequestContext(request, { 59 | 'provider': provider, 60 | }) 61 | t = Template(""" 62 | {% load socialaccount %} 63 | {% provider_login_url provider.id %} 64 | """) 65 | content = t.render(c).strip() 66 | 67 | self.assertEquals(content, "/accounts/adfs_oauth2/login/") 68 | 69 | 70 | class TestProvidersRegistryFindsUs(TestCase): 71 | def test_load(self): 72 | registry = providers.ProviderRegistry() 73 | self.assertFalse(registry.loaded) 74 | self.assertFalse(registry.provider_map) 75 | self.assertNotIn(ADFSOAuth2Provider.id, registry.provider_map) 76 | registry.load() 77 | self.assertIn(ADFSOAuth2Provider.id, registry.provider_map) 78 | provider = registry.by_id(ADFSOAuth2Provider.id) 79 | self.assertIsInstance(provider, ADFSOAuth2Provider) 80 | 81 | 82 | class UtilsTests(TestCase): 83 | def test_guid(self): 84 | data = {"guid": "2brp/e0eREqX7SzEA6JjJA=="} 85 | uid = default_extract_uid_handler(data, None) 86 | self.assertEquals(uid, six.text_type('fde9bad9-1eed-4a44-97ed-2cc403a26324')) 87 | 88 | 89 | class ADFSTests(OAuth2TestsMixin): 90 | provider_id = ADFSOAuth2Provider.id 91 | default_claims = { 92 | "guid": "2brp/e0eREqX7SzEA6JjJA==", 93 | "upn": "foo@bar.example.com", 94 | "first_name": "jane", 95 | "last_name": "doe" 96 | } 97 | 98 | def get_mocked_response(self): 99 | return MockedResponse(200, '') 100 | 101 | def get_login_response_json(self, **kwargs): 102 | jwt = self.get_dummy_jwt() 103 | return '{"access_token":"%s"}' % jwt 104 | 105 | @unittest.skip("refresh tokens are not supported") 106 | def test_account_refresh_token_saved_next_login(self, **kwargs): 107 | pass 108 | 109 | @unittest.skip("cannot match expected token value") 110 | def test_account_tokens(self, **kwargs): 111 | pass 112 | 113 | def get_dummy_jwt(self, claims=None): 114 | if claims is None: 115 | claims = self.default_claims 116 | 117 | # raw data 118 | header = { 119 | "alg": "none", 120 | "typ":"JWT" 121 | } 122 | 123 | signature = "" 124 | 125 | # payload data 126 | header_data = encode(json.dumps(header)) 127 | claims_data = encode(json.dumps(claims)) 128 | signature_data = encode(signature) 129 | payload = [header_data, claims_data, signature_data] 130 | 131 | return ".".join(payload) 132 | 133 | def test_unencrypted_token_payload(self): 134 | jwt = self.get_dummy_jwt() 135 | 136 | encoded_claims_json = parse_token_payload_segment(jwt) 137 | decoded_claims_json = decode_payload_segment(encoded_claims_json) 138 | parsed_claims = json.loads(decoded_claims_json) 139 | 140 | claims = self.default_claims 141 | 142 | self.assertEqual(claims["guid"], parsed_claims["guid"]) 143 | self.assertEqual(claims["upn"], parsed_claims["upn"]) 144 | self.assertEqual(claims["first_name"], parsed_claims["first_name"]) 145 | self.assertEqual(claims["last_name"], parsed_claims["last_name"]) 146 | 147 | 148 | # 149 | # INTEGRATION TESTS REQUIRE AN ACTUAL ADFS SERVER 150 | # THIS ALLOWS US TO RUN TESTS IF THE SERVER IS AVAIALBLE 151 | # LOCALLY BUT STILL RUN OTHER TESTS ON TRAVIS. USE 152 | # HOSTNAME EXPANSION INSTEAD OF HARDCODING THE INTERNAL 153 | # ADFS SERVER ADDRESS. CHECK IS CURR 154 | # 155 | ADFS_SERVER_CNAME = 'sso' 156 | ADFS_SERVER_HOSTNAME = socket.getfqdn(ADFS_SERVER_CNAME) 157 | ADFS_SERVER_DOMAIN_LIST = ADFS_SERVER_HOSTNAME.split('.')[1:] 158 | ADFS_SERVER_DOMAIN = ".".join(ADFS_SERVER_DOMAIN_LIST) 159 | ADFS_SERVER_FQDN = "%s.%s" % (ADFS_SERVER_CNAME, ADFS_SERVER_DOMAIN) 160 | 161 | try: 162 | requests.get('http://%s' % ADFS_SERVER_FQDN, timeout=1) 163 | except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: 164 | ADFS_AVAILABLE = False 165 | print("\nADFS is not available at '%s'. Exception:\n%r\n" % (ADFS_SERVER_FQDN, e)) 166 | else: 167 | ADFS_AVAILABLE = True 168 | 169 | 170 | @unittest.skipUnless(ADFS_AVAILABLE, "requires reachable ADFS server") 171 | @override_settings(SOCIALACCOUNT_PROVIDERS = { 172 | 'adfs_oauth2': { 173 | 'name': 'ADFS Login', 174 | 'host': ADFS_SERVER_FQDN, 175 | 'redirect_uri_protocol': 'http', 176 | 'time_validation_leeway': 30, # allow for 30 seconds of clock drift 177 | 'verify_token': True, 178 | 'AUTH_PARAMS': { 179 | 'resource': 'integration-tests', 180 | }, 181 | } 182 | }) 183 | class IntegrationADFSTests(OAuth2TestsMixin, TestCase): 184 | provider_id = ADFSOAuth2Provider.id 185 | 186 | def setUp(self): 187 | super(IntegrationADFSTests, self).setUp() 188 | factory = RequestFactory() 189 | request = factory.get('/accounts/login/') 190 | adapter = ADFSOAuth2Adapter(request=request) 191 | self.adapter = adapter 192 | 193 | def get_mocked_response(self): 194 | return MockedResponse(200, '') 195 | 196 | @unittest.skip("refresh tokens are not supported") 197 | def test_account_refresh_token_saved_next_login(self, **kwargs): 198 | pass 199 | 200 | @unittest.skip("cannot match expected token value") 201 | def test_account_tokens(self, **kwargs): 202 | pass 203 | 204 | def test_login(self, **kwargs): 205 | # we cannot actually log in, so the xml returned is blank and fails 206 | # but this tests the process up to that point and we were having exceptions 207 | # prior to that point when converting to python3 so this is better than nothing 208 | with self.assertRaises(ExpatError): 209 | super(IntegrationADFSTests, self).test_login(**kwargs) 210 | 211 | def test_verify_true(self): 212 | self.assertTrue(settings.SOCIALACCOUNT_PROVIDERS['adfs_oauth2']['verify_token']) 213 | 214 | def test_access_token_url(self): 215 | expected = "https://%s/adfs/oauth2/token" % ADFS_SERVER_FQDN 216 | self.assertEquals(self.adapter.access_token_url, expected) 217 | 218 | def test_token_signature_key(self): 219 | # this varies, but confirm we get a truthy value and the code runs 220 | self.assertTrue(self.adapter.token_signature_key) 221 | 222 | def test_federation_metadata_xml(self): 223 | self.assertTrue(self.adapter.federation_metadata_xml) 224 | -------------------------------------------------------------------------------- /allauth_adfs/socialaccount/providers/adfs_oauth2/urls.py: -------------------------------------------------------------------------------- 1 | from allauth.socialaccount.providers.oauth.urls import default_urlpatterns 2 | 3 | from .provider import ADFSOAuth2Provider 4 | 5 | urlpatterns = default_urlpatterns(ADFSOAuth2Provider) -------------------------------------------------------------------------------- /allauth_adfs/socialaccount/providers/adfs_oauth2/utils.py: -------------------------------------------------------------------------------- 1 | from six import text_type 2 | from django.utils.encoding import force_bytes, force_text 3 | from uuid import UUID 4 | from struct import pack 5 | from base64 import urlsafe_b64encode, urlsafe_b64decode 6 | from allauth.account.models import EmailAddress 7 | 8 | def decode_payload_segment(s): 9 | """ 10 | reference: 11 | https://github.com/jpadilla/pyjwt/blob/528318787eff3df062f2b55a5f79964aece74f18/jwt/utils.py#L12 12 | """ 13 | if isinstance(s, text_type): 14 | s = s.encode('ascii') 15 | 16 | rem = len(s) % 4 17 | 18 | if rem > 0: 19 | s += b'=' * (4 - rem) 20 | 21 | return urlsafe_b64decode(s) 22 | 23 | def parse_token_payload_segment(t): 24 | """ 25 | reference: 26 | https://github.com/jpadilla/pyjwt/blob/4f899c6764d57000eba0fc40721f9e1b5d94a77a/jwt/api_jws.py#L130 27 | """ 28 | t = force_bytes(t) 29 | try: 30 | signing_input, crypto_segment = t.rsplit(b'.', 1) 31 | header_segment, payload_segment = signing_input.split(b'.', 1) 32 | except ValueError: 33 | raise ValueError('Not enough segments') 34 | 35 | return payload_segment 36 | 37 | def default_extract_uid_handler(data, app): 38 | guid = force_bytes(data['guid']) 39 | raw = urlsafe_b64decode(guid) 40 | uid = UUID(bytes_le=raw) 41 | return text_type(uid) 42 | 43 | def per_social_app_extract_uid_handler(data, app): 44 | guid = force_bytes(data['guid']) 45 | raw = urlsafe_b64decode(guid) 46 | uid = UUID(bytes_le=raw) 47 | return "{};{}".format(app.id, uid) 48 | 49 | def default_extract_common_fields_handler(data, app): 50 | upn = data['upn'] 51 | common_fields = dict( 52 | username = upn.split("@")[0], 53 | first_name = data.get('first_name'), 54 | last_name = data.get('last_name'), 55 | email = data.get('email', upn), 56 | ) 57 | for key in ("is_staff", "is_superuser", "is_active"): 58 | common_fields[key] = data.get(key) == "1" 59 | return common_fields 60 | 61 | def per_social_app_extract_common_fields_handler(data, app): 62 | common_fields = default_extract_common_fields_handler(data, app) 63 | uid_bytes = UUID(default_extract_uid_handler(data, app)).bytes 64 | uid_b64 = urlsafe_b64encode(uid_bytes) 65 | aid_bytes = pack("I", app.id-1)# I format is 0-4294967295 66 | aid_b64 = urlsafe_b64encode(aid_bytes) 67 | username = "".join([uid_b64, aid_b64]).replace("=", "")# length of 28 68 | common_fields["username"] = username 69 | return common_fields 70 | 71 | def default_extract_email_addresses_handler(data, app): 72 | addressess = [] 73 | common_fields = default_extract_common_fields_handler(data, app) 74 | email = common_fields.get("email") 75 | if email: 76 | addressess.append(EmailAddress(email=email, verified=True, primary=True)) 77 | return addressess 78 | 79 | def default_extract_extra_data_handler(data, app): 80 | return data 81 | -------------------------------------------------------------------------------- /allauth_adfs/socialaccount/providers/adfs_oauth2/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import json 3 | 4 | from allauth.socialaccount.providers.oauth2.views import (OAuth2Adapter, 5 | OAuth2LoginView, 6 | OAuth2CallbackView) 7 | from django.core.exceptions import ImproperlyConfigured 8 | from django.utils.six import string_types 9 | from django.utils.encoding import force_bytes 10 | from .provider import ADFSOAuth2Provider 11 | from .utils import decode_payload_segment, parse_token_payload_segment 12 | import requests 13 | from xml.dom.minidom import parseString 14 | from hashlib import md5 15 | 16 | try: 17 | from urllib.parse import urlunsplit 18 | except ImportError: 19 | from urlparse import urlunsplit 20 | 21 | try: 22 | import jwt 23 | from cryptography.x509 import load_der_x509_certificate 24 | from cryptography.hazmat.backends import default_backend 25 | from cryptography.hazmat.primitives import serialization 26 | except ImportError: 27 | JWT_AVAILABLE = False 28 | JWT_ALGORITHM_REQUIRED = False 29 | else: 30 | JWT_AVAILABLE = True 31 | # starting with jwt v2.x, a list of allowed algorithms is required 32 | try: 33 | jwt_version = [int(x) for x in jwt.__version__.split(".")] 34 | JWT_ALGORITHM_REQUIRED = jwt_version[0] >= 2 35 | except: 36 | JWT_ALGORITHM_REQUIRED = False 37 | 38 | from .compat import caches, DEFAULT_CACHE_ALIAS 39 | 40 | 41 | logger = logging.getLogger(__name__) 42 | 43 | 44 | class ADFSOAuth2Adapter(OAuth2Adapter): 45 | provider_id = ADFSOAuth2Provider.id 46 | 47 | def get_setting(self, key, default="", required=True): 48 | value = self.get_provider().get_settings().get(key, default) 49 | if not value and required: 50 | raise ImproperlyConfigured("ADFS OAuth2 provider setting '%s' is required. It must not be falsey." % key) 51 | return value 52 | 53 | @property 54 | def redirect_uri_protocol(self): 55 | value = self.get_setting("redirect_uri_protocol", default=None, required=False) 56 | if isinstance(value, string_types): 57 | value = value.lower() 58 | if value not in ("http", "https", None): 59 | raise ImproperlyConfigured("ADFS OAuth2 provider setting 'redirect_uri_protocol' must be one of 'http', 'https', or None. You supplied '%s'." % value) 60 | return value 61 | 62 | @property 63 | def host(self): 64 | """ 65 | e.g. sso.internal.example.com or sso.example.com:8443 66 | """ 67 | return self.get_setting("host") 68 | 69 | def construct_adfs_url(self, path): 70 | parts = ( 71 | "https", 72 | self.host, 73 | path, 74 | "", 75 | "", 76 | ) 77 | return urlunsplit(parts) 78 | 79 | @property 80 | def access_token_url(self): 81 | return self.construct_adfs_url("/adfs/oauth2/token") 82 | 83 | @property 84 | def authorize_url(self): 85 | return self.construct_adfs_url("/adfs/oauth2/authorize") 86 | 87 | @property 88 | def federation_metadata_url(self): 89 | return self.construct_adfs_url("/FederationMetadata/2007-06/FederationMetadata.xml") 90 | 91 | @property 92 | def federation_metadata_xml(self): 93 | response = requests.get(self.federation_metadata_url) 94 | 95 | if response.status_code == 200: 96 | data = response.content 97 | else: 98 | raise RuntimeError("Could not retrieve federation metadata") 99 | 100 | xml = parseString(data) 101 | 102 | return xml 103 | 104 | @property 105 | def token_signature(self): 106 | cache_alias = self.get_setting( 107 | "token_signature_cache_alias", 108 | default=self.get_setting("token_signature_key_cache_alias", DEFAULT_CACHE_ALIAS), 109 | ) 110 | cache = caches[cache_alias] 111 | hashable_url = force_bytes(self.federation_metadata_url) 112 | cache_key = ":".join([ 113 | "allauth_adfs", 114 | "ADFSOAuth2Adapter", 115 | md5(hashable_url).hexdigest(), 116 | "token_signature", 117 | ]) 118 | 119 | sig_info = cache.get(cache_key) 120 | 121 | if sig_info is None: 122 | xml = self.federation_metadata_xml 123 | 124 | signature = xml.getElementsByTagName("ds:Signature")[0] 125 | algorithm = None 126 | try: 127 | sig_method_algorithm = signature.getElementsByTagName("ds:SignedInfo")[0] \ 128 | .getElementsByTagName("ds:SignatureMethod")[0] \ 129 | .getAttribute("Algorithm") 130 | if sig_method_algorithm == "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256": 131 | algorithm = "RS256" 132 | elif JWT_ALGORITHM_REQUIRED: 133 | raise ImproperlyConfigured("Signature algorithm required, but found unknown/unsupported signature algorithm = %s" % sig_method_algorithm) 134 | except: 135 | if JWT_ALGORITHM_REQUIRED: 136 | raise ImproperlyConfigured("Signature algorithm required but not found in metadata xml") 137 | cert_b64 = signature.getElementsByTagName("X509Certificate")[0].firstChild.nodeValue 138 | 139 | cert_str = decode_payload_segment(cert_b64) 140 | cert_obj = load_der_x509_certificate(cert_str, default_backend()) 141 | 142 | pub = cert_obj.public_key().public_bytes( 143 | encoding=serialization.Encoding.PEM, 144 | format=serialization.PublicFormat.SubjectPublicKeyInfo, 145 | ) 146 | 147 | sig_info = {"pub": pub, "algorithm": algorithm} 148 | 149 | timeout = self.get_setting( 150 | "token_signature_cache_timeout", 151 | default=self.get_setting("token_signature_key_cache_timeout", 0, required=False), 152 | required=False 153 | ) 154 | cache.set(cache_key, sig_info, timeout) 155 | 156 | return sig_info 157 | 158 | @property 159 | def token_signature_key(self): 160 | return self.token_signature["pub"] 161 | 162 | @property 163 | def token_signature_algorithm(self): 164 | return self.token_signature["algorithm"] 165 | 166 | def complete_login(self, request, app, token, **kwargs): 167 | verify_token = self.get_setting("verify_token", True, required=False) 168 | 169 | if verify_token: 170 | if not JWT_AVAILABLE: 171 | raise ImproperlyConfigured("ADFS OAuth2 cannot verify tokens without the `PyJWT` and `cryptography` packages. They can both be installed with pip. The `cryptography` package requires development headers for python and libffi. They can be installed with 'apt-get install python-dev libffi-dev' on Ubuntu Linux. You can disable token verification by setting 'verify_token' to False under the 'adfs_oauth2' socialaccount provider configuration dictionary in `settings.py`. IT IS NOT RECOMMENDED TO DISABLE TOKEN VERIFICATION IN PRODUCTION!") 172 | 173 | kwargs = {"verify": verify_token} 174 | 175 | auth_params = self.get_setting("AUTH_PARAMS") 176 | 177 | try: 178 | kwargs["audience"] = "microsoft:identityserver:%s" % auth_params["resource"] 179 | except KeyError: 180 | raise ImproperlyConfigured("ADFS OAuth2 AUTH_PARAMS setting 'resource' must be specified.") 181 | 182 | kwargs["leeway"] = self.get_setting("time_validation_leeway", 0, required=False) 183 | 184 | kwargs["key"] = self.token_signature_key 185 | 186 | if JWT_ALGORITHM_REQUIRED: 187 | kwargs["algorithms"] = [self.token_signature_algorithm] 188 | 189 | payload = jwt.decode(token.token, **kwargs) 190 | 191 | else: 192 | encoded_data = parse_token_payload_segment(token.token) 193 | data = decode_payload_segment(encoded_data) 194 | payload = json.loads(data) 195 | 196 | logger.info("Retrieved the following token payload from %s:\n%s", self.host, payload) 197 | 198 | return self.get_provider().sociallogin_from_response( 199 | request, 200 | payload 201 | ) 202 | 203 | oauth_login = OAuth2LoginView.adapter_view(ADFSOAuth2Adapter) 204 | oauth_callback = OAuth2CallbackView.adapter_view(ADFSOAuth2Adapter) 205 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | # docs at https://www.appveyor.com/docs/lang/python/ 2 | build: off 3 | environment: 4 | global: 5 | PYTHON: C:/Python27 6 | matrix: 7 | - TOXENV=py27-django-111 8 | - TOXENV=py34-django-111 9 | - TOXENV=py34-django-20 10 | - TOXENV=py35-django-111 11 | - TOXENV=py35-django-20 12 | - TOXENV=py35-django-master 13 | - TOXENV=py36-django-111 14 | - TOXENV=py36-django-20 15 | - TOXENV=py36-django-master 16 | allow_failures: 17 | - TOXENV: py34-django-master 18 | - TOXENV: py35-django-master 19 | - TOXENV: py36-django-master 20 | install: 21 | - SET PATH=%PYTHON%;%PYTHON%/Scripts;%PATH% 22 | - pip install -U tox virtualenv 23 | test_script: 24 | - tox 25 | 26 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from setuptools.command.test import test as SetuptoolsTestCommand 3 | from shlex import split 4 | from sys import version_info 5 | 6 | class RunTestsCommand(SetuptoolsTestCommand): 7 | user_options = [ 8 | ('only=', 'o', 'Only run the specified tests'), 9 | ('level=', 'l', 'Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output'), 10 | ('suppress-coverage-report', None, 'Suppress coverage report'), 11 | ] 12 | def initialize_options(self): 13 | SetuptoolsTestCommand.initialize_options(self) 14 | self.test_suite = "override" 15 | self.only = "" 16 | self.level = "1" 17 | self.suppress_coverage_report = None 18 | 19 | def finalize_options(self): 20 | SetuptoolsTestCommand.finalize_options(self) 21 | self.test_suite = None 22 | self.level = int(self.level) 23 | self.suppress_coverage_report = self.suppress_coverage_report is not None 24 | 25 | def run(self): 26 | SetuptoolsTestCommand.run(self) 27 | self.with_project_on_sys_path(self.run_tests) 28 | 29 | def run_tests(self): 30 | import coverage.cmdline 31 | import os 32 | import subprocess 33 | import sys 34 | import time 35 | 36 | owd = os.path.abspath(os.getcwd()) 37 | nwd = os.path.abspath(os.path.dirname(__file__)) 38 | os.chdir(nwd) 39 | tests = split(self.only) 40 | if not tests: 41 | tests.extend([nwd, os.path.abspath('test_project')]) 42 | errno = coverage.cmdline.main(['run', os.path.abspath('test_project/manage.py'), 'test', '--verbosity=%d' % self.level] + tests) 43 | 44 | if not self.suppress_coverage_report: 45 | coverage.cmdline.main(['report', '-m']) 46 | 47 | if None not in [os.getenv("TRAVIS", None), os.getenv("TRAVIS_JOB_ID", None), os.getenv("TRAVIS_BRANCH", None)]: 48 | env = os.environ.copy() 49 | env["PYTHONPATH"] = os.pathsep.join(sys.path) 50 | cmd = ["coveralls"] 51 | coveralls_retry = 5 52 | while subprocess.call(cmd, env=env) and coveralls_retry: 53 | coveralls_retry -= 1 54 | if coveralls_retry: 55 | seconds = 10 56 | print("coveralls was unsuccessful. sleeping for %s seconds before retrying." % seconds) 57 | time.sleep(seconds) 58 | else: 59 | print("coveralls failed.") 60 | 61 | os.chdir(owd) 62 | 63 | raise SystemExit(errno) 64 | 65 | jwt_require = ["PyJWT", "cryptography"] 66 | 67 | pki_require = ["certifi"] 68 | if version_info < (3, 0): 69 | pki_require = pki_require + ["pyopenssl", "ndg-httpsclient"] 70 | 71 | tests_require = ['coverage', 'beautifulsoup4', 'html5lib', 'coveralls'] + jwt_require 72 | if version_info < (3, 3): 73 | tests_require = tests_require + ['mock==2.0.0', 'pbr<1.7.0'] 74 | 75 | setup( 76 | name = "django-allauth-adfs", 77 | version = "0.1.6", 78 | author = "gordon", 79 | author_email = "wgordonw1@gmail.com", 80 | description = "ADFS oAuth provider for django-allauth", 81 | url = "https://github.com/thenewguy/django-allauth-adfs", 82 | cmdclass={'test': RunTestsCommand}, 83 | packages=find_packages(), 84 | extras_require={ 85 | "jwt": jwt_require, 86 | "pki": pki_require, 87 | }, 88 | install_requires=['django-allauth>=0.26.0', 'six'], 89 | tests_require=tests_require, 90 | classifiers = [ 91 | 'Programming Language :: Python', 92 | 'Operating System :: OS Independent', 93 | 'Framework :: Django', 94 | ], 95 | ) 96 | -------------------------------------------------------------------------------- /test_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /test_project/test_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thenewguy/django-allauth-adfs/44497a2aa9c76a0fa2b2556594d7796597298284/test_project/test_project/__init__.py -------------------------------------------------------------------------------- /test_project/test_project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for test_project project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.8.4. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.8/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.8/ref/settings/ 11 | """ 12 | 13 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 14 | import os 15 | 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | # adjust sys.path to find project application 19 | import sys 20 | sys.path.insert(1, os.path.abspath(os.path.join(BASE_DIR, "../"))) 21 | 22 | # Quick-start development settings - unsuitable for production 23 | # See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/ 24 | 25 | # SECURITY WARNING: keep the secret key used in production secret! 26 | SECRET_KEY = 'not-so-secret' 27 | SITE_ID = 1 28 | 29 | # SECURITY WARNING: don't run with debug turned on in production! 30 | DEBUG = True 31 | 32 | ALLOWED_HOSTS = [] 33 | 34 | 35 | # Application definition 36 | 37 | INSTALLED_APPS = ( 38 | 'django.contrib.admin', 39 | 'django.contrib.auth', 40 | 'django.contrib.contenttypes', 41 | 'django.contrib.sessions', 42 | 'django.contrib.messages', 43 | 'django.contrib.staticfiles', 44 | 'django.contrib.sites', 45 | 'allauth_adfs', 46 | 'allauth_adfs.socialaccount.providers.adfs_oauth2', 47 | 'allauth', 48 | 'allauth.account', 49 | 'allauth.socialaccount', 50 | ) 51 | 52 | MIDDLEWARE = ( 53 | 'django.contrib.sessions.middleware.SessionMiddleware', 54 | 'django.middleware.common.CommonMiddleware', 55 | 'django.middleware.csrf.CsrfViewMiddleware', 56 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 57 | 'django.contrib.messages.middleware.MessageMiddleware', 58 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 59 | 'django.middleware.security.SecurityMiddleware', 60 | ) 61 | 62 | ROOT_URLCONF = 'test_project.urls' 63 | 64 | TEMPLATES = [ 65 | { 66 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 67 | 'DIRS': [], 68 | 'APP_DIRS': True, 69 | 'OPTIONS': { 70 | 'context_processors': [ 71 | 'django.template.context_processors.debug', 72 | 'django.template.context_processors.request', 73 | 'django.contrib.auth.context_processors.auth', 74 | 'django.contrib.messages.context_processors.messages', 75 | ], 76 | }, 77 | }, 78 | ] 79 | 80 | WSGI_APPLICATION = 'test_project.wsgi.application' 81 | 82 | 83 | # Database 84 | # https://docs.djangoproject.com/en/1.8/ref/settings/#databases 85 | 86 | DATABASES = { 87 | 'default': { 88 | 'ENGINE': 'django.db.backends.sqlite3', 89 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 90 | } 91 | } 92 | 93 | 94 | # Internationalization 95 | # https://docs.djangoproject.com/en/1.8/topics/i18n/ 96 | 97 | LANGUAGE_CODE = 'en-us' 98 | 99 | TIME_ZONE = 'UTC' 100 | 101 | USE_I18N = True 102 | 103 | USE_L10N = True 104 | 105 | USE_TZ = True 106 | 107 | 108 | # Static files (CSS, JavaScript, Images) 109 | # https://docs.djangoproject.com/en/1.8/howto/static-files/ 110 | 111 | STATIC_URL = '/static/' 112 | 113 | 114 | SOCIALACCOUNT_PROVIDERS = { 115 | 'adfs_oauth2': { 116 | 'name': 'ADFS Login', 117 | 'host': 'localhost', 118 | 'redirect_uri_protocol': 'http', 119 | 'time_validation_leeway': 30, # allow for 30 seconds of clock drift 120 | 'verify_token': False, 121 | 'AUTH_PARAMS': { 122 | 'resource': 'adfs_oauth2_tests', 123 | }, 124 | } 125 | } -------------------------------------------------------------------------------- /test_project/test_project/urls.py: -------------------------------------------------------------------------------- 1 | """test_project URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.8/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Add an import: from blog import urls as blog_urls 14 | 2. Add a URL to urlpatterns: url(r'^blog/', include(blog_urls)) 15 | """ 16 | from django.conf.urls import include, url 17 | from django.contrib import admin 18 | 19 | urlpatterns = [ 20 | url(r'^admin/', admin.site.urls), 21 | url(r'^accounts/', include('allauth.urls')), 22 | ] 23 | -------------------------------------------------------------------------------- /test_project/test_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for test_project project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | toxworkdir={env:TOX_WORK_DIR:.tox} 3 | args_are_paths = false 4 | envlist = 5 | {py27}-django-{111} 6 | {py34}-django-{111,20} 7 | {py35,py36,py37}-django-{111,20,21,22,master} 8 | 9 | [testenv] 10 | passenv = REQUESTS_CA_BUNDLE TRAVIS TRAVIS_JOB_ID TRAVIS_BRANCH 11 | basepython = 12 | py27: python2.7 13 | py34: python3.4 14 | py35: python3.5 15 | py36: python3.6 16 | py37: python3.7 17 | usedevelop = true 18 | pip_pre = true 19 | deps = 20 | coveralls 21 | django-111: Django>=1.11,<1.12 22 | django-20: Django>=2.0,<2.1 23 | django-21: Django>=2.1,<2.2 24 | django-22: Django>=2.2,<2.3 25 | django-master: https://github.com/django/django/archive/master.tar.gz 26 | commands = 27 | python --version 28 | python -c "import platform; print(platform.architecture())" 29 | python -c "import platform; print(platform.machine())" 30 | python -c "import platform; print(platform.node())" 31 | python -c "import platform; print(platform.platform())" 32 | python -c "import platform; print(platform.processor())" 33 | python -c "import platform; print(platform.python_build())" 34 | python -c "import platform; print(platform.python_compiler())" 35 | python -c "import platform; print(platform.python_branch())" 36 | python -c "import platform; print(platform.python_implementation())" 37 | python -c "import platform; print(platform.python_revision())" 38 | python -c "import platform; print(platform.python_version())" 39 | python -c "import platform; print(platform.release())" 40 | python -c "import platform; print(platform.system())" 41 | python -c "import platform; print(platform.version())" 42 | python -c "import platform; print(platform.system_alias(platform.system(), platform.release(), platform.version()))" 43 | python -c "import platform; print(platform.uname())" 44 | pip freeze 45 | python {toxinidir}/setup.py test {env:SUPPRESS_COVERAGE_REPORT:} 46 | -------------------------------------------------------------------------------- /vagrant/Vagrantfile: -------------------------------------------------------------------------------- 1 | host = RbConfig::CONFIG['host_os'] 2 | HOST_IS_MAC = host =~ /darwin/ 3 | HOST_IS_LINUX = host =~ /linux/ 4 | HOST_IS_WINDOWS = host =~ /mswin|mingw|cygwin/ 5 | 6 | if HOST_IS_MAC 7 | HOST_MEM = `sysctl -n hw.memsize`.to_i / 1024 / 1024 8 | HOST_CPUS = `sysctl -n hw.ncpu`.to_i 9 | elsif HOST_IS_LINUX 10 | HOST_MEM = `grep 'MemTotal' /proc/meminfo | sed -e 's/MemTotal://' -e 's/ kB//'`.to_i / 1024 11 | HOST_CPUS = `nproc`.to_i 12 | elsif HOST_IS_WINDOWS 13 | HOST_MEM = `wmic computersystem Get TotalPhysicalMemory`.split[1].to_i / 1024 / 1024 14 | HOST_CPUS = `wmic cpu Get NumberOfCores`.split[1].to_i 15 | end 16 | 17 | Vagrant.configure("2") do |config| 18 | config.vm.boot_timeout = 600 19 | config.vm.box = "bento/ubuntu-14.04" 20 | config.vm.box_url = "https://vagrantcloud.com/bento/boxes/ubuntu-14.04/versions/201802.02.0/providers/virtualbox.box" 21 | 22 | cpus = HOST_CPUS 23 | if 7000 < HOST_MEM 24 | mem = 4096 25 | else 26 | mem = 2048 27 | end 28 | 29 | config.vm.provider "virtualbox" do |v| 30 | v.name = "django-allauth-adfs" 31 | v.memory = mem 32 | v.cpus = cpus 33 | if cpus > 1 34 | v.customize ["modifyvm", :id, "--ioapic", "on"] 35 | end 36 | v.customize ["modifyvm", :id, "--cpuexecutioncap", "75"] 37 | end 38 | 39 | 40 | config.vm.provision :shell, path: "provision.sh" 41 | config.vm.synced_folder ".", "/vagrant", disabled: true 42 | config.vm.synced_folder "../", "/vagrant" 43 | 44 | 45 | # forward ports as listed in vagrant/vagrant/rebuild.sh 46 | # 47 | ## 48 | ## 49 | ## THIS ALLOWS THE WEB BROWSER ON THE HOST MACHINE 50 | ## TO COMMUNICATE VIA '127.0.0.1' or 'localhost' 51 | ## i.e. `curl -i http://127.0.0.1:8080/` 52 | ## 53 | ## THIS ALSO ALLOWS NETWORKED MACHINES TO ACCESS FORWARDED 54 | ## PORTS VIA THE HOST 55 | ## i.e. `curl -i http://host-ip-or-fqdn:8080/ 56 | ## 57 | ## 58 | 59 | # responder http (use 8080 to avoid sudo requirement) 60 | config.vm.network "forwarded_port", guest: 80, host: 8080 61 | 62 | end 63 | -------------------------------------------------------------------------------- /vagrant/provision.sh: -------------------------------------------------------------------------------- 1 | set -o errexit 2 | set -o pipefail 3 | set -o nounset 4 | shopt -s failglob 5 | set -o xtrace 6 | 7 | export DEBIAN_FRONTEND=noninteractive 8 | 9 | add-apt-repository ppa:deadsnakes/ppa 10 | 11 | apt-get update 12 | 13 | apt-get install -y git python3.5 python3.6 14 | 15 | # install awscli and awsebcli under python 3 16 | curl -O https://bootstrap.pypa.io/get-pip.py 17 | python get-pip.py 18 | 19 | pip install tox 20 | 21 | # install domain certificate if available 22 | cp /vagrant/domain.crt /usr/local/share/ca-certificates/domain.crt || echo COULD NOT COPY DOMAIN TRUST 23 | ls /usr/local/share/ca-certificates/domain.crt && update-ca-certificates 24 | rm -f /etc/profile.d/REQUESTS_CA_BUNDLE.sh 25 | ls /usr/local/share/ca-certificates/domain.crt && echo 'export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt' > /etc/profile.d/REQUESTS_CA_BUNDLE.sh 26 | --------------------------------------------------------------------------------