diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e691088 --- /dev/null +++ b/.gitignore @@ -0,0 +1,134 @@ +# Created by https://www.gitignore.io/api/macos,python + +### macOS ### +*.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.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 +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule.* + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + + +# End of https://www.gitignore.io/api/macos,python diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f2615b9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,25 @@ +BSD 2-Clause License + +Copyright (c) 2015, Kristian Oellegaard +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* 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. diff --git a/saml_service_provider/tests/__init__.py b/saml_service_provider/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/saml_service_provider/tests/test_auth_backend.py b/saml_service_provider/tests/test_auth_backend.py new file mode 100644 index 0000000..669f1ce --- /dev/null +++ b/saml_service_provider/tests/test_auth_backend.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import User +import mock + +from saml_service_provider.auth_backend import SAMLServiceProviderBackend +from saml_service_provider.tests.utils import SamlServiceProviderTestCase + + +class SAMLServiceProviderBackendTestCase(SamlServiceProviderTestCase): + + NEW_USER_USERNAME = 'jdoe' + NEW_USER_FIRST_NAME = 'John' + NEW_USER_LAST_NAME = 'Doe' + + @classmethod + def setUpTestData(cls): + super(SAMLServiceProviderBackendTestCase, cls).setUpTestData() + cls.auth_backend = SAMLServiceProviderBackend() + + def testNoAuthenticationMeansDifferentBackend(self): + self.assertIsNone(self.auth_backend.authenticate()) + + def testNoUserIsReturnedIfNoneIsAuthenticated(self): + saml_authentication = mock.Mock(is_authenticated=lambda: False) + self.assertIsNone(self.auth_backend.authenticate(saml_authentication)) + + def testExistingUserIsAuthenticated(self): + # Authenticate with the SAMLServiceProvider backend + saml_authentication = mock.Mock(is_authenticated=lambda: True, get_nameid=lambda: self.USER_USERNAME) + user = self.auth_backend.authenticate(saml_authentication) + + # Verify that the user authenticated is the existing user + self.assertEquals(user, User.objects.get(username=self.USER_USERNAME)) + + def testNewUserIsCreatedAndAuthenticated(self): + # Count the number of users + num_users = User.objects.count() + + # Authenticate with the SAMLServiceProvider backend + saml_authentication = mock.Mock( + is_authenticated=lambda: True, + get_attributes=lambda: {'First name': [self.NEW_USER_FIRST_NAME], 'Last name': [self.NEW_USER_LAST_NAME]}, + get_nameid=lambda: self.NEW_USER_USERNAME + ) + user = self.auth_backend.authenticate(saml_authentication) + + # Verify that the user authenticated is the new user + self.assertEquals(user, User.objects.get(username=self.NEW_USER_USERNAME)) + + # Verify that the user has the first and last name attributes set + self.assertEquals(user.first_name, self.NEW_USER_FIRST_NAME) + self.assertEquals(user.last_name, self.NEW_USER_LAST_NAME) + + # Verify that a new user was created + self.assertEquals(User.objects.count(), num_users + 1) + + def testGetUserUsesAuthUser(self): + # Verify that the user is looked up by PK + self.assertEquals(self.auth_backend.get_user(self.user.pk), self.user) + + # Verify that no user is returned when an invalid PK is provided + invalid_pk = User.objects.order_by('pk').last().pk + 1 + self.assertIsNone(self.auth_backend.get_user(invalid_pk)) diff --git a/saml_service_provider/tests/test_settings.py b/saml_service_provider/tests/test_settings.py new file mode 100644 index 0000000..2fbb5f7 --- /dev/null +++ b/saml_service_provider/tests/test_settings.py @@ -0,0 +1,31 @@ +import base64 +import hashlib +import unittest + +from saml_service_provider.settings import OneloginServiceProviderSettings + + +class SAMLServiceProviderSettingsTestCase(unittest.TestCase): + + def testOneloginServiceProviderSettingsRequiresCertOrFingerprint(self): + with self.assertRaises(Exception) as e: + OneloginServiceProviderSettings() + self.assertEquals(str(e.exception), "Please provider either onelogin_x509_cert or onelogin_x509_fingerprint") + + def testOneloginX509CertSetsIDPX509Cert(self): + x509_cert = base64.b64encode('abc123') + settings = OneloginServiceProviderSettings(onelogin_x509_cert=x509_cert).settings + + # Verify that the IDP X509 cert matches the one provided to OneloginServiceProviderSettings + self.assertIn('idp', settings) + self.assertIn('x509cert', settings['idp']) + self.assertEquals(settings['idp']['x509cert'], x509_cert) + + def testOneloginX509FingerprintSetsIDPX509Fingerprint(self): + x509_fingerprint = hashlib.sha1('abc123').hexdigest + settings = OneloginServiceProviderSettings(onelogin_x509_fingerprint=x509_fingerprint).settings + + # Verify that the IDP X509 fingerprint matches the one provided to OneloginServiceProviderSettings + self.assertIn('idp', settings) + self.assertIn('certFingerprint', settings['idp']) + self.assertEquals(settings['idp']['certFingerprint'], x509_fingerprint) diff --git a/saml_service_provider/tests/test_views.py b/saml_service_provider/tests/test_views.py new file mode 100644 index 0000000..bb7ab79 --- /dev/null +++ b/saml_service_provider/tests/test_views.py @@ -0,0 +1,170 @@ +import base64 +import os + +from django.contrib.auth import get_user +import mock + +from saml_service_provider.settings import OneloginServiceProviderSettings +from saml_service_provider.tests.utils import SamlServiceProviderTestCase + + +class InitiateAuthenticationViewTestCase(SamlServiceProviderTestCase): + + def assertStartsWith(self, full_string, expected_substring): + return self.assertEquals(full_string[:len(expected_substring)], expected_substring) + + @mock.patch('saml_service_provider.views.OneloginMixin.get_onelogin_settings') + def testInitiateAuthenticationRedirectsToIDP(self, mock_get_onelogin_settings): + mock_get_onelogin_settings.return_value = OneloginServiceProviderSettings( + onelogin_x509_cert=base64.b64encode('abc123'), + sp_metadata_url=self.METADATA_URL, + sp_login_url='{root}/complete-login/'.format(root=self.ROOT_URL), + sp_x509cert="", + sp_private_key="" + ).settings + + # Verify that the user is not yet authenticated + user = get_user(self.client) + self.assertFalse(user.is_authenticated()) + + # Verify that unauthenticated requests to initate login redirect the user to complete login + response = self.client.get('/initiate-login/', follow=True, HTTP_HOST=self.HOST) + redirect_url, redirect_status_code = response.redirect_chain[0] + self.assertEquals(redirect_status_code, 302) + self.assertStartsWith(redirect_url, 'https://app.onelogin.com/trust/saml2/http-post/sso/') + + +class CompleteAuthenticationViewTestCase(SamlServiceProviderTestCase): + + @classmethod + def generate_saml_response(cls, username, first_name, last_name): + # Read in SAML response from testdata + cwd = os.path.dirname(os.path.abspath(__file__)) + saml_response_xml_filename = os.path.join(cwd, 'testdata', 'saml-response.xml') + with open(saml_response_xml_filename, 'r') as f: + saml_response_xml = f.read().format( + metadata_url=cls.METADATA_URL, + username=username, + first_name=first_name, + last_name=last_name + ) + return base64.b64encode(saml_response_xml) + + @classmethod + def generate_basic_saml_response(cls): + return cls.generate_saml_response( + username=cls.USER_USERNAME, + first_name=cls.USER_FIRST_NAME, + last_name=cls.USER_LAST_NAME + ) + + @mock.patch('onelogin.saml2.response.OneLogin_Saml2_Response.is_valid') + @mock.patch('saml_service_provider.views.OneloginMixin.get_onelogin_settings') + def testUserRedirectsToRelayState(self, mock_get_onelogin_settings, mock_is_valid): + mock_get_onelogin_settings.return_value = OneloginServiceProviderSettings( + onelogin_x509_cert=base64.b64encode('abc123'), + sp_metadata_url=self.METADATA_URL, + sp_login_url='{root}/complete-login/'.format(root=self.ROOT_URL), + sp_x509cert="", + sp_private_key="" + ).settings + mock_is_valid.return_value = True + + # Verify that the user is not yet authenticated + user = get_user(self.client) + self.assertFalse(user.is_authenticated()) + + # Simulate a POST request from OneLogin to log a user in + relay_url = '/relay/' + data = { + 'SAMLResponse': self.generate_basic_saml_response(), + 'RelayState': relay_url, + } + response = self.client.post( + '/complete-login/', + data=data, + follow=True, + HTTP_HOST=self.HOST + ) + + # Verify that the user is now authenticated + user = get_user(self.client) + self.assertTrue(user.is_authenticated()) + + # Verify that the user is the one in the SAML response + self.assertEquals(user.username, self.USER_USERNAME) + + # Verify that the user redirects to the relay + redirect_url, redirect_status_code = response.redirect_chain[0] + self.assertEquals(redirect_status_code, 302) + self.assertEquals(redirect_url.replace(self.ROOT_URL, ''), relay_url) + + @mock.patch('onelogin.saml2.response.OneLogin_Saml2_Response.is_valid') + @mock.patch('saml_service_provider.views.OneloginMixin.get_onelogin_settings') + def testUserRedirectsToRootWithoutRelayState(self, mock_get_onelogin_settings, mock_is_valid): + mock_get_onelogin_settings.return_value = OneloginServiceProviderSettings( + onelogin_x509_cert=base64.b64encode('abc123'), + sp_metadata_url=self.METADATA_URL, + sp_login_url='{root}/complete-login/'.format(root=self.ROOT_URL), + sp_x509cert="", + sp_private_key="" + ).settings + mock_is_valid.return_value = True + + # Simulate a POST request from OneLogin to log a user in + response = self.client.post( + '/complete-login/', + data={'SAMLResponse': self.generate_basic_saml_response()}, + follow=True, + HTTP_HOST=self.HOST + ) + + # Verify that the user is now authenticated + user = get_user(self.client) + self.assertTrue(user.is_authenticated()) + + # Verify that the user redirects to the root + redirect_url, redirect_status_code = response.redirect_chain[0] + self.assertEquals(redirect_status_code, 302) + self.assertEquals(redirect_url.replace(self.ROOT_URL, ''), '/') + + @mock.patch('saml_service_provider.views.OneloginMixin.get_onelogin_settings') + def testBadSamlResponse(self, mock_get_onelogin_settings): + mock_get_onelogin_settings.return_value = OneloginServiceProviderSettings( + onelogin_x509_cert=base64.b64encode('abc123'), + sp_metadata_url=self.METADATA_URL, + sp_login_url='{root}/complete-login/'.format(root=self.ROOT_URL), + sp_x509cert="", + sp_private_key="" + ).settings + + # Simulate a POST request with an invalid SAMLResponse + # This response is invalid naturally, since we're not + # mocking the OneLogin_Saml2_Response.is_valid() method + response = self.client.post( + '/complete-login/', + data={'SAMLResponse': self.generate_basic_saml_response()}, + HTTP_HOST=self.HOST + ) + + # Verify that the user is returned a 400 + self.assertEquals(response.status_code, 400) + + +class MetadataViewTestCase(SamlServiceProviderTestCase): + + @mock.patch('saml_service_provider.views.OneloginMixin.get_onelogin_settings') + def testMetadataView(self, mock_get_onelogin_settings): + mock_get_onelogin_settings.return_value = OneloginServiceProviderSettings( + onelogin_x509_cert=base64.b64encode('abc123'), + sp_metadata_url=self.METADATA_URL, + sp_login_url='{root}/complete-login/'.format(root=self.ROOT_URL), + sp_x509cert="", + sp_private_key="" + ).settings + + # Verify that the metadata requests render successfully + response = self.client.get('/metadata/', HTTP_HOST=self.HOST) + self.assertEquals(response.status_code, 200) + self.assertIn('Content-Type', response) + self.assertEquals(response['Content-Type'], 'text/xml') diff --git a/saml_service_provider/tests/testdata/saml-response.xml b/saml_service_provider/tests/testdata/saml-response.xml new file mode 100644 index 0000000..0e566bd --- /dev/null +++ b/saml_service_provider/tests/testdata/saml-response.xml @@ -0,0 +1,33 @@ + + http://idp.example.com/metadata.php + + + + + http://idp.example.com/metadata.php + + {username} + + + + + + + {metadata_url} + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + + + + {first_name} + + + {last_name} + + + + diff --git a/saml_service_provider/tests/utils.py b/saml_service_provider/tests/utils.py new file mode 100644 index 0000000..a04f528 --- /dev/null +++ b/saml_service_provider/tests/utils.py @@ -0,0 +1,18 @@ +from django.contrib.auth.models import User +from django.test import TestCase + + +class SamlServiceProviderTestCase(TestCase): + + HOST = 'sp.example.com' + ROOT_URL = 'http://{host}'.format(host=HOST) + METADATA_URL = '{root}/metadata/'.format(root=ROOT_URL) + + USER_USERNAME = 'msmith' + USER_FIRST_NAME = 'Mary' + USER_LAST_NAME = 'Smith' + + @classmethod + def setUpTestData(cls): + # Create user + cls.user = User.objects.create_user(username=cls.USER_USERNAME) diff --git a/saml_service_provider/views.py b/saml_service_provider/views.py index ebe458b..45d6e1f 100644 --- a/saml_service_provider/views.py +++ b/saml_service_provider/views.py @@ -1,6 +1,5 @@ from django.contrib.auth import login, authenticate from django.core.exceptions import PermissionDenied -from django.core.urlresolvers import reverse from django.http import HttpResponseRedirect, HttpResponseBadRequest, HttpResponse, HttpResponseServerError from django.views.generic import View from onelogin.saml2.auth import OneLogin_Saml2_Auth @@ -8,12 +7,15 @@ from saml_service_provider.utils import prepare_from_django_request from django.conf import settings + class OneloginMixin(object): + def get_onelogin_settings(self): raise NotImplementedError("Please define a get_saml_settings method on this view") class InitiateAuthenticationView(OneloginMixin, View): + def get(self, *args, **kwargs): req = prepare_from_django_request(self.request) auth = OneLogin_Saml2_Auth(req, self.get_onelogin_settings()) @@ -24,6 +26,7 @@ def get(self, *args, **kwargs): class CompleteAuthenticationView(OneloginMixin, View): + def post(self, request): req = prepare_from_django_request(request) auth = OneLogin_Saml2_Auth(req, self.get_onelogin_settings()) @@ -33,8 +36,10 @@ def post(self, request): if auth.is_authenticated(): user = authenticate(saml_authentication=auth) login(self.request, user) - if 'RelayState' in req['post_data'] and \ - OneLogin_Saml2_Utils.get_self_url(req) != req['post_data']['RelayState']: + if ( + 'RelayState' in req['post_data'] + and OneLogin_Saml2_Utils.get_self_url(req) != req['post_data']['RelayState'] + ): return HttpResponseRedirect(auth.redirect_to(req['post_data']['RelayState'])) else: return HttpResponseRedirect("/") @@ -47,6 +52,7 @@ def post(self, request): class MetadataView(OneloginMixin, View): + def get(self, request, *args, **kwargs): req = prepare_from_django_request(request) auth = OneLogin_Saml2_Auth(req, self.get_onelogin_settings()) @@ -56,4 +62,4 @@ def get(self, request, *args, **kwargs): if len(errors) == 0: return HttpResponse(content=metadata, content_type='text/xml') else: - return HttpResponseServerError(content=', '.join(errors)) \ No newline at end of file + return HttpResponseServerError(content=', '.join(errors)) diff --git a/setup.py b/setup.py index a952de4..0ed021f 100644 --- a/setup.py +++ b/setup.py @@ -1,9 +1,47 @@ -from setuptools import setup, find_packages +from setuptools import Command, find_packages, setup + from saml_service_provider import __version__ as version + +class TestCommand(Command): + + user_options = [] + + def initialize_options(self): + pass + + def finalize_options(self): + pass + + def run(self): + import django + from django.conf import settings + from django.core.management import call_command + + settings.configure( + DATABASES={ + 'default': { + 'NAME': ':memory:', + 'ENGINE': 'django.db.backends.sqlite3', + }, + }, + INSTALLED_APPS=( + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + ), + MIDDLEWARE_CLASSES=('django.contrib.sessions.middleware.SessionMiddleware',), + ROOT_URLCONF='saml_service_provider.urls', + AUTHENTICATION_BACKENDS=['saml_service_provider.auth_backend.SAMLServiceProviderBackend'] + ) + django.setup() + call_command('test', 'saml_service_provider') + + setup( name='django-saml-service-provider', version=version, + license='BSD License', description='Easily let users sign in via SAML 2.0 to your django app.', long_description='', author='Kristian Oellegaard', @@ -13,8 +51,11 @@ zip_safe=False, include_package_data=True, install_requires=[ - 'Django>=1.4', - 'python-saml' + 'Django >= 1.4', + 'python-saml', + ], + tests_require=[ + 'mock', ], classifiers=[ "Development Status :: 5 - Production/Stable", @@ -23,5 +64,6 @@ "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 2.7", - ] + ], + cmdclass={'test': TestCommand} )