Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
mhindery committed Mar 14, 2017
0 parents commit f123a78
Show file tree
Hide file tree
Showing 44 changed files with 1,652 additions and 0 deletions.
21 changes: 21 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
*.sw[a-z]
*.py[oc]
*.log

*.egg-info/
*.tox/

*.sqlite3
*.db

.env

.cache
.coverage

# Python release artifacts
build/
dist/
_build/

tags
17 changes: 17 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
language: python
python:
- "2.7"
- "3.4"
- "3.5"
- "3.6"
env:
- DJANGO_VERSION=1.8
- DJANGO_VERSION=1.9
- DJANGO_VERSION=1.10
- DJANGO_VERSION=1.11b1
install:
- pip install -q Django==$DJANGO_VERSION
- pip install -q -r requirements-dev.txt
- python setup.py install
script:
- python -Wall runtests.py
19 changes: 19 additions & 0 deletions LICENSE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
Copyright (c) 2015 Mobify Research & Development Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
4 changes: 4 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
include README.md
include LICENSE.txt
recursive-include djangosaml2idp/templates docs *
global-exclude *.pyc *.sqlite *.db
131 changes: 131 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
djangosaml2idp
===============

.. image:: https://img.shields.io/pypi/v/djangosaml2idp.svg
:target: https://pypi.python.org/pypi/djangosaml2idp
:alt: PyPi

.. image:: https://travis-ci.org/mhindery/djangosaml2idp.svg?branch=master
:target: https://travis-ci.org/mhindery/djangosaml2idp
:alt: Travis CI

.. image:: https://landscape.io/github/mhindery/djangosaml2idp/master/landscape.svg?style=flat
:target: https://landscape.io/github/mhindery/djangosaml2idp/master
:alt: Code Health

.. image:: https://lima.codeclimate.com/github/mhindery/djangosaml2idp/badges/gpa.svg
:target: https://lima.codeclimate.com/github/mhindery/djangosaml2idp
:alt: Code Climate

.. image:: https://requires.io/github/mhindery/djangosaml2idp/requirements.svg?branch=master
:target: https://requires.io/github/mhindery/djangosaml2idp/requirements/?branch=master
:alt: Requirements Status


djangosaml2idp implements the Identity Provider side of the SAML2 protocol with Django.
It builds on top of PySAML2_, is compatible with Python 2/3 and all current supported Django versions.

.. _PySAML2: https://github.com/rohe/pysaml2/

Installation
------------

PySAML2 uses xmlsec1_ binary to sign SAML assertions so you need to install
it either through your operating system package or by compiling the source
code. It doesn't matter where the final executable is installed because
you will need to set the full path to it in the configuration stage.
``xmlsec`` is available (at least) for Debian, OSX and Alpine Linux.

.. _xmlsec1: http://www.aleksey.com/xmlsec/

Now you can install the djangosaml2idp package using pip. This
will also install PySAML2 and its dependencies automatically::

pip install djangosaml2idp


Configuration & Usage
---------------------
The first thing you need to do is add ``djangosaml2idp`` to the list of installed apps::

INSTALLED_APPS = (
'django.contrib.admin',
'djangosaml2idp',
...
)

Now include ``djangosaml2idp`` in your project by adding it in the url config::

from django.conf.urls import url, include
from django.contrib import admin

urlpatterns = [
url(r'^idp/', include('djangosaml2idp.urls')),
url(r'^admin/', admin.site.urls),
...
]

In your Django settings, configure your IdP. Configuration follows the pysaml2_configuration_. The IdP from the example project looks like this::

...
import saml2
from saml2.saml import NAMEID_FORMAT_EMAILADDRESS, NAMEID_FORMAT_UNSPECIFIED
from saml2.sigver import get_xmlsec_binary

BASE_URL = "http://localhost:9000/idp"

SAML_IDP_CONFIG = {
"entityid": "%s/metadata" % BASE_URL,
"description": "Example IdP setup",
"valid_for": 168,
"service": {
"idp": {
"name": "Django localhost IdP",
"endpoints": {
"single_sign_on_service": [
("%s/sso/post" % BASE_URL, saml2.BINDING_HTTP_POST),
("%s/sso/redirect" % BASE_URL, saml2.BINDING_HTTP_REDIRECT),
],
},
"name_id_format": [NAMEID_FORMAT_EMAILADDRESS, NAMEID_FORMAT_UNSPECIFIED],
'sign_response': True,
'sign_assertion': True,
},
},
'metadata': {
'local': [os.path.join(os.path.join(os.path.join(BASE_DIR, 'idp'), 'saml2_config'), 'sp_metadata.xml')],
},
# Signing
'key_file': BASE_DIR + '/certificates/private_key.pem', # private part
'cert_file': BASE_DIR + '/certificates/public_key.pem', # public part
# Encryption
'encryption_keypairs': [{
'key_file': BASE_DIR + '/certificates/private_key.pem', # private part
'cert_file': BASE_DIR + '/certificates/public_key.pem', # public part
}],
"xmlsec_binary": get_xmlsec_binary(["/opt/local/bin", "/usr/bin/xmlsec1"]),
'debug' : DEBUG,
}

You also have to define a mapping for each SP you talk to::

SAML_IDP_ACS_ATTRIBUTE_MAPPING = {
"http://localhost:8000/saml2/metadata/": {
# Map Django user attributes to output SAML attributes
'email': 'email',
'first_name': 'first_name',
'last_name': 'last_name',
'is_staff': 'is_staff',
'is_superuser': 'is_superuser',
}
}

That's all for the IdP configuration. Assuming you run the Django development server on localhost:8000, you can get its metadata by visiting http://localhost:8000/idp/metadata/.
Use this metadata xml to configure your SP. Place the metadata xml from that SP in the location specified in the config dict (sp_metadata.xml in the example above).

.. _pysaml2_configuration: https://github.com/rohe/pysaml2/blob/master/doc/howto/config.rst

Example project
---------------
``example_project`` contains a barebone demo setup.
It consists of a Service Provider implemented with ``djangosaml2`` and an Identity Provider using ``djangosaml2idp``.
4 changes: 4 additions & 0 deletions djangosaml2idp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, print_function, unicode_literals

__version__ = '0.2.0'
13 changes: 13 additions & 0 deletions djangosaml2idp/templates/djangosaml2idp/login.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<head>
<title>SAML 2.0 POST</title>
</head>
<body>
<form method="post" action="{{ acs_url }}">
<input type="hidden" name="RelayState" value="{{ relay_state }}" />
<input type="hidden" name="SAMLResponse" value="{{ saml_response }}" />
<input type="submit" value="Submit" />
</form>
{% if autosubmit %}
<script type="text/javascript"> window.onload = function () { document.forms[0].submit(); }</script>
{% endif %}
</body>
11 changes: 11 additions & 0 deletions djangosaml2idp/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from __future__ import absolute_import, division, print_function, unicode_literals

from django.conf.urls import url
from . import views

urlpatterns = [
url(r'^sso/post', views.sso_entry, name="saml_login_post"),
url(r'^sso/redirect', views.sso_entry, name="saml_login_redirect"),
url(r'^login/process/$', views.login_process, name='saml_login_process'),
url(r'^metadata/$', views.metadata, name='saml2_idp_metadata'),
]
131 changes: 131 additions & 0 deletions djangosaml2idp/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-
from __future__ import (absolute_import, division, print_function,
unicode_literals)

import copy
import logging

from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.urlresolvers import reverse
from django.http import (HttpResponse, HttpResponseBadRequest,
HttpResponseRedirect, HttpResponseServerError)
from django.utils.datastructures import MultiValueDictKeyError
from django.views.decorators.csrf import csrf_exempt
from saml2 import BINDING_HTTP_POST, BINDING_HTTP_REDIRECT
from saml2.authn_context import PASSWORD, AuthnBroker, authn_context_class_ref
from saml2.config import IdPConfig
from saml2.ident import NameID
from saml2.metadata import entity_descriptor
from saml2.s_utils import UnknownPrincipal, UnsupportedBinding
from saml2.server import Server
from six import text_type

logger = logging.getLogger(__name__)


@csrf_exempt
def sso_entry(request):
passed_data = request.POST if request.method == 'POST' else request.GET
try:
request.session['SAMLRequest'] = passed_data['SAMLRequest']
except (KeyError, MultiValueDictKeyError) as e:
return HttpResponseBadRequest(e)
request.session['RelayState'] = passed_data.get('RelayState', '')
# For HTTP-REDIRECT endpoint
if "SigAlg" in passed_data and "Signature" in passed_data:
request.session['SigAlg'] = passed_data['SigAlg']
request.session['Signature'] = passed_data['Signature']
return HttpResponseRedirect(reverse('saml_login_process'))


def create_identity(user, sp_mapping):
identity = {}
for user_attr, out_attr in sp_mapping.items():
if hasattr(user, user_attr):
identity[out_attr] = getattr(user, user_attr)
return identity


# TODO Add http redirect logic based on https://github.com/rohe/pysaml2/blob/master/example/idp2_repoze/idp.py#L327
@login_required
def login_process(request):
"""
Processor-based login continuation.
Presents a SAML 2.0 Assertion for POSTing back to the Service Provider.
"""
# Construct server with config from settings dict
conf = IdPConfig()
conf.load(copy.deepcopy(settings.SAML_IDP_CONFIG))
IDP = Server(config=conf)
# Parse incoming request
try:
req_info = IDP.parse_authn_request(request.session['SAMLRequest'], BINDING_HTTP_POST)
_authn_req = req_info.message
except Exception as excp:
return HttpResponseBadRequest(excp)

# Signed request for HTTP-REDIRECT
if "SigAlg" in request.session and "Signature" in request.session:
_certs = IDP.metadata.certs(_authn_req.issuer.text, "any", "signing")
verified_ok = False
for cert in _certs:
# TODO implement
#if verify_redirect_signature(_info, IDP.sec.sec_backend, cert):
# verified_ok = True
# break
pass
if not verified_ok:
return HttpResponseBadRequest("Message signature verification failure")

binding_out, destination = IDP.pick_binding(
service="assertion_consumer_service",
entity_id=_authn_req.issuer.text)

# Gather response arguments
try:
resp_args = IDP.response_args(_authn_req)
except (UnknownPrincipal, UnsupportedBinding) as excp:
return HttpResponseServerError(excp)

# Create Identity dict (SP-specific)
try:
sp_mapping = settings.SAML_IDP_ACS_ATTRIBUTE_MAPPING.get(resp_args['sp_entity_id'], {'username': 'username'})
except AttributeError:
identity = {'username': 'username'}
identity = create_identity(request.user, sp_mapping)

# TODO investigate how this works, because I don't get it
req_authn_context = _authn_req.requested_authn_context or PASSWORD
AUTHN_BROKER = AuthnBroker()
AUTHN_BROKER.add(authn_context_class_ref(req_authn_context), "")

# Construct SamlResponse message
try:
authn_resp = IDP.create_authn_response(
identity=identity, userid=request.user.username,
name_id=NameID(format=resp_args['name_id_policy'].format, sp_name_qualifier=destination, text=request.user.username),
authn=AUTHN_BROKER.get_authn_by_accr(req_authn_context),
sign_response=IDP.config.getattr("sign_response", "idp") or False,
sign_assertion=IDP.config.getattr("sign_assertion", "idp") or False,
**resp_args)
except Exception as excp:
return HttpResponseServerError(excp)

# Return as html with self-submitting form.
http_args = IDP.apply_binding(
binding=binding_out,
msg_str="%s" % authn_resp,
destination=destination,
relay_state=request.session['RelayState'],
response=True)
return HttpResponse(http_args['data'])


def metadata(request):
"""Returns an XML with the SAML 2.0 metadata for this Idp as configured in the settings.py file.
"""
conf = IdPConfig()
conf.load(copy.deepcopy(settings.SAML_IDP_CONFIG))
metadata = entity_descriptor(conf)
return HttpResponse(content=text_type(metadata).encode('utf-8'), content_type="text/xml; charset=utf8")
20 changes: 20 additions & 0 deletions docs/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#

# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
SPHINXPROJ = djangosaml2idp
SOURCEDIR = .
BUILDDIR = _build

# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

.PHONY: help Makefile

# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
Loading

0 comments on commit f123a78

Please sign in to comment.