Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simplify Setup for a Django Project #12

Open
wants to merge 21 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[run]
source = ./

[report]
exclude_lines =
pragma: no cover

# Ignore defensive assertion code / illegal states
raise AssertionError
raise NotImplementedError

# Ignore pass (often used in abstract methods)
pass
18 changes: 18 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
language: python
python:
- 2.7

env:
- DJANGO_VERSION=1.8.18
- DJANGO_VERSION=1.9.13
- DJANGO_VERSION=1.10.8
- DJANGO_VERSION=1.11.9

install:
- sudo apt-get install python-dev libxml2-dev libxmlsec1-dev
- pip install -q coverage python-saml Django==$DJANGO_VERSION

script: coverage run ./setup.py test

after_success:
- bash <(curl -s https://codecov.io/bash)
55 changes: 24 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,42 +2,35 @@
Easily let users sign in via SAML 2.0 to your django app. Based on python-saml and comes with a Onelogin.com provider, so you
need to do very little work to get started.

[![Build Status](https://travis-ci.org/KristianOellegaard/django-saml-service-provider.svg?branch=master)](https://travis-ci.org/KristianOellegaard/django-saml-service-provider)
[![codecov](https://codecov.io/gh/KristianOellegaard/django-saml-service-provider/branch/master/graph/badge.svg)](https://codecov.io/gh/KristianOellegaard/django-saml-service-provider)

# Get started
You need to extend the three default views provided by this library and use your own settings. It can be done easily with
a single mixin. Consider the following simple example, using the Onelogin provider. You can also do the same with the
regular SAMLServiceProvider - you just need to provide all the urls manually.
If you are using OneLogin as your identity provider, you can simply add the following to your `urls.py` file to add
all necessary authentication views:

```python
# urls.py
urlpatterns = [
url(r'^saml/', include('saml_service_provider.urls')),
...
]
```

For these views to work, you will have to add a few new settings to your `settings.py` file:

```python
class SettingsMixin(object):
def get_onelogin_settings(self):
return OneloginServiceProviderSettings(
onelogin_connector_id=1234,
onelogin_x509_cert="""-----BEGIN CERTIFICATE-----
-----END CERTIFICATE-----""",

sp_metadata_url="http://localhost:8000%s" % reverse("saml_metadata"),
sp_login_url="http://localhost:8000%s" % reverse("saml_login_complete"),
sp_logout_url="http://localhost:8000%s" % reverse("logout"),

debug=settings.DEBUG,
strict=not settings.DEBUG,

sp_x509cert="""-----BEGIN CERTIFICATE-----
-----END CERTIFICATE-----""",
sp_private_key="""-----BEGIN RSA PRIVATE KEY-----
-----END RSA PRIVATE KEY-----"""
).settings

class LoginView(SettingsMixin, InitiateAuthenticationView):
pass

class Authenticateview(SettingsMixin, CompleteAuthenticationView):
pass

class XMLMetadataView(SettingsMixin, MetadataView):
pass
# settings.py
ONELOGIN_CONNECTOR_ID = 1234
ONELOGIN_X509_CERT = """-----BEGIN CERTIFICATE-----
-----END CERTIFICATE-----""" # You may provide ONELOGIN_X509_FINGERPRINT instead
SP_METADATA_URL = 'http://localhost:8000/saml/metadata/' # from saml_service_provider.urls
SP_LOGIN_URL = 'http://localhost:8000/saml/initiate-login/' # from saml_service_provider.urls
SP_LOGOUT_URL = 'http://localhost:8000/logout/'
```

If you are using a different service provider, you will have to initialize `saml_service_provider.settings.SAMLServiceProviderSettings` directly, and you will need to create your own views, overriding the views in `saml_service_provider.views`.

# Django authentication backend
This project conveniently ships with an authentication backend - just add it to AUTHENTICATION_BACKENDS in settings and you're
good to go - e.g.:
Expand Down
2 changes: 1 addition & 1 deletion saml_service_provider/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '1.0.2'
__version__ = '1.0.2'
42 changes: 32 additions & 10 deletions saml_service_provider/auth_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,48 @@

class SAMLServiceProviderBackend(object):

NAMEID_ATTRIBUTE = 'username'
ATTRIBUTE_NAME_FIRST_NAME = 'First name'
ATTRIBUTE_NAME_LAST_NAME = 'Last name'

@staticmethod
def get_attribute_or_none(attributes, attribute_name):
try:
return attributes[attribute_name][0]
except IndexError:
return

def post_init_user(self, user):
pass

def update_attributes(self, user, attributes):
# Set name
user.first_name = self.get_attribute_or_none(attributes, self.ATTRIBUTE_NAME_FIRST_NAME) or ''
user.last_name = self.get_attribute_or_none(attributes, self.ATTRIBUTE_NAME_LAST_NAME) or ''
user.save()

def authenticate(self, saml_authentication=None):
if not saml_authentication: # Using another authentication method
return None
return

if saml_authentication.is_authenticated():
attributes = saml_authentication.get_attributes()
kwargs = {self.NAMEID_ATTRIBUTE: saml_authentication.get_nameid()}
try:
user = User.objects.get(username=saml_authentication.get_nameid())
user = User.objects.get(**kwargs)
except User.DoesNotExist:
user = User(username=saml_authentication.get_nameid())
user = User(**kwargs)
user.set_unusable_password()
user.first_name = attributes['First name'][0]
user.last_name = attributes['Last name'][0]
user.save()
return user
return None
self.post_init_user(user)

# Set attributes (and create user, if not yet created)
attributes = saml_authentication.get_attributes()
self.update_attributes(user, attributes)

return user
return

def get_user(self, user_id):
try:
return User.objects.get(pk=user_id)
except User.DoesNotExist:
return None
return
42 changes: 22 additions & 20 deletions saml_service_provider/settings.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@

class SAMLServiceProviderSettings(object):

contact_info = {
# Contact information template, it is recommended to suply a
# technical and support contacts.
"technical": {
"givenName": "technical_name",
"emailAddress": "[email protected]"
"emailAddress": "[email protected]",
},
"support": {
"givenName": "support_name",
"emailAddress": "[email protected]"
}
"emailAddress": "[email protected]",
},
}

organization_info = {
Expand All @@ -19,19 +19,22 @@ class SAMLServiceProviderSettings(object):
"en-US": {
"name": "organization",
"displayname": "Organization Name",
"url": "https://www.example.org/"
}
"url": "https://www.example.org/",
},
}

def __init__(self,
debug=False,
strict=True,
sp_metadata_url=None, sp_login_url=None, sp_logout_url=None, sp_x509cert=None, sp_private_key=None, # Service provider settings (e.g. us)
idp_metadata_url=None, idp_sso_url=None, idp_slo_url=None, idp_x509cert=None, idp_x509_fingerprint=None, # Identify provider settings (e.g. onelogin)
def __init__(
self,
debug=False,
strict=True,

# Service provider settings (e.g. us)
sp_metadata_url=None, sp_login_url=None, sp_logout_url=None, sp_x509cert=None, sp_private_key=None,
# Identify provider settings (e.g. onelogin)
idp_metadata_url=None, idp_sso_url=None, idp_slo_url=None, idp_x509cert=None, idp_x509_fingerprint=None,
):
super(SAMLServiceProviderSettings, self).__init__()
self.settings = default_settings = {
self.settings = {
# If strict is True, then the Python Toolkit will reject unsigned
# or unencrypted messages if it expects them to be signed or encrypted.
# Also it will reject the messages if the SAML standard is not strictly
Expand All @@ -53,7 +56,7 @@ def __init__(self,
# SAML protocol binding to be used when returning the <Response>
# message. OneLogin Toolkit supports this endpoint for the
# HTTP-POST binding only.
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
},
# Specifies info about where and how the <Logout Response> message MUST be
# returned to the requester, in this case our SP.
Expand All @@ -63,7 +66,7 @@ def __init__(self,
# SAML protocol binding to be used when returning the <Response>
# message. OneLogin Toolkit supports the HTTP-Redirect binding
# only for this endpoint.
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
},
# Specifies the constraints on the name identifier to be used to
# represent the requested subject.
Expand All @@ -72,7 +75,7 @@ def __init__(self,
# Usually x509cert and privateKey of the SP are provided by files placed at
# the certs folder. But we can also provide them with the following parameters
'x509cert': sp_x509cert,
'privateKey': sp_private_key
'privateKey': sp_private_key,
},

# Identity Provider Data that we want connected with our SP.
Expand All @@ -87,7 +90,7 @@ def __init__(self,
# SAML protocol binding to be used when returning the <Response>
# message. OneLogin Toolkit supports the HTTP-Redirect binding
# only for this endpoint.
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
},
# SLO endpoint info of the IdP.
"singleLogoutService": {
Expand All @@ -96,14 +99,13 @@ def __init__(self,
# SAML protocol binding to be used when returning the <Response>
# message. OneLogin Toolkit supports the HTTP-Redirect binding
# only for this endpoint.
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
},
# Public x509 certificate of the IdP
"x509cert": idp_x509cert,
# Instead of use the whole x509cert you can use a fingerprint
# (openssl x509 -noout -fingerprint -in "idp.crt" to generate it)
"certFingerprint": idp_x509_fingerprint

"certFingerprint": idp_x509_fingerprint,
},
# Security settings
# "security": {
Expand Down Expand Up @@ -174,4 +176,4 @@ def __init__(self, onelogin_connector_id=None, onelogin_x509_cert=None, onelogin
kwargs['idp_x509_fingerprint'] = onelogin_x509_fingerprint
else:
raise Exception("Please provider either onelogin_x509_cert or onelogin_x509_fingerprint")
super(OneloginServiceProviderSettings, self).__init__(**kwargs)
super(OneloginServiceProviderSettings, self).__init__(**kwargs)
Empty file.
89 changes: 89 additions & 0 deletions saml_service_provider/tests/test_auth_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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'
NEW_USER_ATTRIBUTES = {'First name': [NEW_USER_FIRST_NAME], 'Last name': [NEW_USER_LAST_NAME]}

@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_attributes=lambda: self.NEW_USER_ATTRIBUTES,
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: self.NEW_USER_ATTRIBUTES,
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 testNewUserWithoutNamesIsCreated(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': [], '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 does not have a name
self.assertEquals(user.first_name, '')
self.assertEquals(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))
31 changes: 31 additions & 0 deletions saml_service_provider/tests/test_settings.py
Original file line number Diff line number Diff line change
@@ -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)
Loading