first commit

This commit is contained in:
Benjamin Dauvergne 2014-04-28 14:33:04 +02:00
commit a1646489e2
20 changed files with 839 additions and 0 deletions

2
COPYING Normal file
View File

@ -0,0 +1,2 @@
{{ project_name }} is entirely under the copyright of Entr'ouvert and distributed
under the license AGPLv3 or later.

217
README Normal file
View File

@ -0,0 +1,217 @@
django-mellon
=============
SAML 2.0 authentication for Django
Usage
=====
You need to have the Python binding for the Lasso library installed, you can
find source and package for Debian on http://lasso.entrouvert.org/download/.
Add mellon to your installed apps::
INSTALLED_APPS = (
...
'mellon',
)
Add the SAMLBacked to your authentication backends::
AUTHENTICATION_BACKENDS = (
...
'mellon.backends.SAMLBackend',
)
Add mellon urls to your urls::
urlpatterns = patterns('',
...
url(r'^/accounts/mellon', include('mellon.urls')),
)
If SAML 2.0 should be your only authentication method you can define `mellon_login` as you main `LOGIN_URL`::
LOGIN_URL = 'mellon_login'
Yout metadata will be downloadable through HTTP on
http://youapplication/base/accounts/mellon/metadata
If your identity provider ask for your assertion consumer URL it's on
http://youapplication/base/accounts/mellon/login
If your identity provider ask for your logout URL it's on
http://youapplication/base/accounts/mellon/logout
Session
=======
After an authentication attributes are stored in the session using a
dictionnary, the key is `mellon_session`. The dictionnary contains:
- issuer: the EntityID of the identity provider
- name_id_content: the value of the NameID
- name_id_format: the format of the NameID
- authn_instant: the ISO8601 date of the authentication on the identity provider, optional.
- session_not_on_or_after: the ISO8691 date after which the local
session should be closed. Note that we automatically set the
expiration of the Django session to this value if it's available.
- authn_context_class_ref: the authentication method of the current
authentication on the identity provider. You can restrict
authorized authentication methods using the setting
`MELLON_AUTHN_CLASSREF`.
- all attributes extracted from the assertion.
Settings
========
All generic setting apart from `MELLON_IDENTITY_PROVIDERS` can be
overridden in the identity provider settings by removing the
`MELLON_` prefix.
MELLON_IDENTITY_PROVIDERS
-------------------------
A list of dictionaries, only one key is mandatory in those
dictionaries `METADATA` it should contain the UTF-8 content of the
metadata file of the identity provider or if it starts with a slash
the absolute path toward a metadata file. All other keys are override
of generic settings.
MELLON_PUBLIC_KEYS
------------------
List of public keys of this service provider, add multiple keys for
doing key roll-over
MELLON_PRIVATE_KEY
------------------
The PKCS#8 PEM encoded private key, if not provided request will not
be signed.
MELLON_PRIVATE_KEY_PASSWORD
---------------------------
Password for the private key if needed, default is None
MELLON_NAME_ID_FORMATS
----------------------
NameID formats to advertise in the metadata file, default is ().
MELLON_NAME_ID_POLICY_FORMAT
----------------------------
The NameID format to request, default is None.
MELLON_FORCE_AUTHN
------------------
Whether to force authentication on each authencation request,
default is False.
MELLON_ADAPTER
--------------
A list of class providings methods handling SAML authorization, user
lookup and provisioning. Optional methods on theses classes are
- authorize(idp, saml_attributes) -> boolean
If any adapter returns False, the authentication is refused. It's
possible to raise PermissionDenied to show a specific message on
the login interface.
- lookup_user(idp, saml_attributes) -> User / None
Each adapter is called in the order of the settings, the first
return value which is not None is kept as the authenticated user.
- provision(user, idp, saml_attributes -> None
This method is there to fill an existing user fields with data
from the SAML attributes or to provision any kind of object in the
application.
Settings of the default adapter
===============================
The following settings are used by the default adapter
`mellon.adapters.DefaulAdapter` if you use your own adapter you can
ignore them. If your adapter inherit from the default adapter those
settings can still be applicable.
MELLON_REALM
------------
The default realm to associate to user created with the default
adapter, default is 'saml'.
MELLON_PROVISION
----------------
Whether to create user if their username does not already exists,
default is True.
MELLON_USERNAME_TEMPLATE
------------------------
The template to build and/or retrieve a user from its username based
on received attributes, the syntax is the one from the str.format()
method of Python. Available variables are:
- realm
- idp (current setting for the idp issuing the assertion)
- attributes
The default value is `{attributes{name_id_content]}@realm`.
Another example could be `{atttributes[uid][0]}` to set the passed
username as the username of the newly created user.
MELLON_ATTRIBUTE_MAPPING
------------------------
Maps templates based on SAML attributes to field of the user model.
Default is {}. To copy standard LDAP attributes into your Django user
model could for example do that::
MELLON_ATTRIBUTE_MAPPING = {
'email': '{attributes[mail][0]',
'first_name': '{attributes[gn][0]}',
'last_name': '{attributes[sn][0]}',
}
MELLON_SUPERUSER_MAPPING
------------------------
Attributes superuser flags to user if a SAML attribute contains a given value,
default is {}. Ex.::
MELLON_SUPERUSER_MAPPING = {
'roles': 'Admin',
}
MELLON_AUTHN_CLASSREF
---------------------
Authorized authentication class references, default is (). Empty
value means everything is authorized. Authentication class reference
must be obtained from your identity provider but SHOULD come from the
SAML 2.0 specification.
MELLON_GROUP_ATTRIBUTE
----------------------
Name of the SAML attribute to map to Django group names, default is None. Ex.:
MELLON_GROUP_ATTRIBUTE = 'role'
MELLON_CREATE_GROUP
-------------------
Whether to create group or only assign existing groups, default is True.

1
mellon/__init__.py Normal file
View File

@ -0,0 +1 @@
__version__ = '1.0.0'

112
mellon/adapters.py Normal file
View File

@ -0,0 +1,112 @@
import logging
from django.core.exceptions import PermissionDenied
from django.contrib import auth
from django.contrib.auth.models import Group
from . import utils
log = logging.getLogger(__name__)
class DefaultAdapter(object):
def authorize(self, idp, saml_attributes):
if not idp:
return False
required_classref = utils.get_parameter(idp, 'AUTHN_CLASSREF')
if required_classref:
given_classref = saml_attributes['authn_context_class_ref']
if given_classref is None or \
given_classref not in required_classref:
raise PermissionDenied
return True
def format_username(self, idp, saml_attributes):
realm = utils.get_parameter(idp, 'REALM')
username_template = utils.get_parameter(idp, 'USERNAME_TEMPLATE')
try:
username = username_template.format(
realm=realm, attributes=saml_attributes, idp=idp)
except ValueError:
log.error('invalid username template %r'. username_template)
except (AttributeError, KeyError, IndexError), e:
log.error('invalid reference in username template %r: %s',
username_template, e)
except Exception, e:
log.exception('unknown error when formatting username')
else:
return username
def lookup_user(self, idp, saml_attributes):
User = auth.get_user_model()
username = self.format_username(idp, saml_attributes)
if not username:
return None
provision = utils.get_parameter(idp, 'PROVISION')
if provision:
user, created = User.objects.get_or_create(username=username)
else:
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
return
return user
def provision(self, user, idp, saml_attributes):
self.provision_attribute(user, idp, saml_attributes)
self.provision_superuser(user, idp, saml_attributes)
self.provision_groups(user, idp, saml_attributes)
def provision_attribute(self, user, idp, saml_attributes):
realm = utils.get_parameter(idp, 'REALM')
attribute_mapping = utils.get_parameter(idp, 'ATTRIBUTE_MAPPING')
for field, tpl in attribute_mapping.iteritems():
try:
value = tpl.format(realm=realm, attributes=saml_attributes, idp=idp)
except ValueError:
log.warning('invalid attribute mapping template %r', tpl)
except (AttributeError, KeyError, IndexError, ValueError), e:
log.warning('invalid reference in attribute mapping template %r: %s', tpl, e)
else:
setattr(user, field, value)
def provision_superuser(self, user, idp, saml_attributes):
superuser_mapping = utils.get_parameter(idp, 'SUPERUSER_MAPPING')
if not superuser_mapping:
return
for key, values in superuser_mapping.iteritems():
if key in saml_attributes:
if not isinstance(values, (tuple, list)):
values = [values]
values = set(values)
attribute_values = saml_attributes[key]
if not isinstance(attribute_values, (tuple, list)):
attribute_values = [attribute_values]
attribute_values = set(attribute_values)
if attribute_values & values:
user.is_staff = True
user.is_superuser = True
user.save()
break
else:
if user.is_superuser:
user.is_superuser = False
user.save()
def provision_groups(self, user, idp, saml_attributes):
group_attribute = utils.get_parameter(idp, 'GROUP_ATTRIBUTE')
create_group = utils.get_parameter(idp, 'CREATE_GROUP')
if group_attribute in saml_attributes:
values = saml_attributes[group_attribute]
if not isinstance(values, (list, tuple)):
values = [values]
groups = []
for value in set(values):
if create_group:
group, created = Group.objects.get_or_create(name=value)
else:
try:
group = Group.objects.get(name=value)
except Group.DoesNotExist:
continue
groups.append(group)
user.groups = groups

3
mellon/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

45
mellon/app_settings.py Normal file
View File

@ -0,0 +1,45 @@
import sys
from django.core.exceptions import ImproperlyConfigured
class AppSettings(object):
__PREFIX = 'MELLON_'
__DEFAULTS = {
'PUBLIC_KEYS': (),
'PRIVATE_KEY': None,
'PRIVATE_KEY_PASSWORD': None,
'NAME_ID_FORMATS': (),
'NAME_ID_POLICY_FORMAT': None,
'FORCE_AUTHN': False,
'ADAPTER': (
'mellon.adapters.DefaultAdapter',
),
'REALM': 'saml',
'PROVISION': True,
'USERNAME_TEMPLATE': '{attributes[name_id_content]}@{realm}',
'ATTRIBUTE_MAPPING': {},
'SUPERUSER_MAPPING': {},
'AUTHN_CLASSREF': (),
'GROUP_ATTRIBUTE': None,
'CREATE_GROUP': True,
}
@property
def IDENTITY_PROVIDERS(self):
from django.conf import settings
try:
idps = settings.MELLON_IDENTITY_PROVIDERS
except AttributeError:
raise ImproperlyConfigured('The MELLON_IDENTITY_PROVIDERS setting is mandatory')
if isinstance(idps, dict):
idps = [idps]
return idps
def __getattr__(self, name):
from django.conf import settings
if name not in self.__DEFAULTS:
raise AttributeError
return getattr(settings, self.__PREFIX + name, self.__DEFAULTS[name])
app_settings = AppSettings()
app_settings.__name__ = __name__
sys.modules[__name__] = app_settings

27
mellon/backends.py Normal file
View File

@ -0,0 +1,27 @@
from django.contrib.auth.backends import ModelBackend
from . import utils
class SAMLBackend(ModelBackend):
def authenticate(self, saml_attributes):
if not 'issuer' in saml_attributes:
return
idp = utils.get_idp(saml_attributes['issuer'])
adapters = utils.get_adapters(idp)
for adapter in adapters:
if not hasattr(adapter, 'authorize'):
continue
if not adapter.authorize(idp, saml_attributes):
return False
for adapter in adapters:
if not hasattr(adapter, 'lookup_user'):
continue
user = adapter.lookup_user(idp, saml_attributes)
if user:
break
for adapter in adapters:
if not hasattr(adapter, 'provision'):
continue
adapter.provision(user, idp, saml_attributes)
return user

View File

@ -0,0 +1,13 @@
#: templates/mellon/inactive_user.html:6
#, python-format
msgid ""
"Your user, %(user)s, \n"
" is inactive please contact your administrator.\n"
" "
msgstr ""
"L'utilisateur « %(user)s est inactif, veuillez "
"contacter votre administrateur."
#: templates/mellon/user_not_found.html:6
msgid "No user found for NameID %(name_id)s"
msgstr "Aucun utilisateur trouvé pour l'identifiant SAML %(name_id)s"

View File

3
mellon/models.py Normal file
View File

@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

View File

View File

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<p>
{% blocktrans with name_id=saml_attributes.name_id_content %}Your user, {{ user }},
is inactive please contact your administrator.
{% endblocktrans %}
</p>
{% endblock %}

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<EntityDescriptor
entityID="{{ entity_id }}"
xmlns="urn:oasis:names:tc:SAML:2.0:metadata">
<SPSSODescriptor
AuthnRequestsSigned="true"
WantAssertionsSigned="true"
protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
{% for public_key in public_keys %}
<KeyDescriptor>
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>
{{ public_key }}
</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</KeyDescriptor>
{% endfor %}
<SingleLogoutService
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="{{ logout_url }}" />
{% for name_id_format in name_id_formats %}
<NameIDFormat>{{ name_id_format }}</NameIDFormat>
{% endfor %}
<AssertionConsumerService
index="0"
isDefault="true"
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="{{ login_url }}" />
<AssertionConsumerService
index="1"
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact"
Location="{{ login_url }}" />
</SPSSODescriptor>
</EntityDescriptor>

View File

@ -0,0 +1,11 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<p>
{% blocktrans with name_id=saml_attributes.name_id_content %}No user found for NameID {{ name_id }}{% endblocktrans %}
</p>
<pre style="display: none">
{{ saml_attributes|pprint }}
</pre>
{% endblock %}

3
mellon/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

12
mellon/urls.py Normal file
View File

@ -0,0 +1,12 @@
from django.conf.urls import patterns, url
from . import views
urlpatterns = patterns('',
url('^accounts/mellon/login/$', views.login,
name='mellon_login'),
url('^accounts/mellon/logout/$', views.logout,
name='mellon_logout'),
url('^accounts/mellon/metadata/$', views.metadata,
name='mellon_metadata'),
)

113
mellon/utils.py Normal file
View File

@ -0,0 +1,113 @@
import re
import time
import datetime
import importlib
from functools import wraps
from xml.etree import ElementTree as ET
from django.core.urlresolvers import reverse
from django.template.loader import render_to_string
import lasso
from . import app_settings
METADATA = {}
def create_metadata(request):
entity_id = reverse('mellon_metadata')
if entity_id not in METADATA:
login_url = reverse('mellon_login')
logout_url = reverse('mellon_logout')
public_keys = []
for public_key in app_settings.PUBLIC_KEYS:
if public_key.startswith('/'):
public_key = file(public_key).read()
public_keys.append(public_key)
name_id_formats = app_settings.NAME_ID_FORMATS
return render_to_string('mellon/metadata.xml', {
'entity_id': request.build_absolute_uri(entity_id),
'login_url': request.build_absolute_uri(login_url),
'logout_url': request.build_absolute_uri(logout_url),
'public_keys': public_keys,
'name_id_formats': name_id_formats,
})
return METADATA[entity_id]
SERVERS = {}
def create_server(request):
root = request.build_absolute_uri('/')
if root not in SERVERS:
idps = app_settings.IDENTITY_PROVIDERS
metadata = create_metadata(request)
server = lasso.Server.newFromBuffers(metadata,
private_key_content=app_settings.PRIVATE_KEY,
private_key_password=app_settings.PRIVATE_KEY_PASSWORD)
for idp in idps:
metadata = idp['METADATA']
if metadata.startswith('/'):
metadata = file(metadata).read()
idp['ENTITY_ID'] = ET.fromstring(metadata).attrib['entityID']
server.addProviderFromBuffer(lasso.PROVIDER_ROLE_IDP, metadata)
SERVERS[root] = server
return SERVERS[root]
def create_login(request):
server = create_server(request)
login = lasso.Login(server)
if not app_settings.PRIVATE_KEY:
login.setSignatureHint(lasso.PROFILE_SIGNATURE_HINT_FORBID)
return login
def get_idp(entity_id):
for idp in app_settings.IDENTITY_PROVIDERS:
if idp['ENTITY_ID'] == entity_id:
return idp
def flatten_datetime(d):
for key, value in d.iteritems():
if isinstance(value, datetime.datetime):
d[key] = value.isoformat() + 'Z'
return d
def iso8601_to_datetime(date_string):
'''Convert a string formatted as an ISO8601 date into a time_t
value.
This function ignores the sub-second resolution'''
m = re.match(r'(\d+-\d+-\d+T\d+:\d+:\d+)(?:\.\d+)?Z$', date_string)
if not m:
raise ValueError('Invalid ISO8601 date')
tm = time.strptime(m.group(1)+'Z', "%Y-%m-%dT%H:%M:%SZ")
return datetime.datetime.fromtimestamp(time.mktime(tm))
def to_list(func):
@wraps(func)
def f(*args, **kwargs):
return list(func(*args, **kwargs))
return f
def import_object(path):
module, name = path.rsplit('.', 1)
module = importlib.import_module(module)
return getattr(module, name)
@to_list
def get_adapters(idp):
idp = idp or {}
adapters = idp.get('ADAPTER') or app_settings.ADAPTER
for adapter in adapters:
yield import_object(adapter)()
def get_values(saml_attributes, name):
values = saml_attributes.get(name)
if values is None:
return ()
if not isinstance(values, (list, tuple)):
return (values,)
return values
def get_parameter(idp, name):
return idp.get(name) or getattr(app_settings, name)

129
mellon/views.py Normal file
View File

@ -0,0 +1,129 @@
import logging
from django.views.generic import View
from django.http import HttpResponseBadRequest, HttpResponseRedirect, HttpResponse
from django.contrib import auth
from django.conf import settings
from django.views.decorators.csrf import csrf_exempt
from django.shortcuts import render
import lasso
from . import app_settings, utils
log = logging.getLogger(__name__)
class LoginView(View):
def get_idp(self, request):
entity_id = request.REQUEST.get('entity_id')
if not entity_id:
return app_settings.IDENTITY_PROVIDERS[0]
else:
for idp in app_settings.IDENTITY_PROVIDERS:
if idp.entity_id == entity_id:
return idp
def post(self, request, *args, **kwargs):
'''Assertion consumer'''
if 'SAMLResponse' not in request.POST:
return self.get(request, *args, **kwargs)
login = utils.create_login(request)
try:
login.processAuthnResponseMsg(request.POST['SAMLResponse'])
login.acceptSso()
except lasso.Error, e:
return HttpResponseBadRequest('error processing the authentication '
'response: %r' % e)
name_id = login.nameIdentifier
attributes = {}
attribute_statements = login.assertion.attributeStatement
for ats in attribute_statements:
for at in ats.attribute:
values = attributes.setdefault(at.name, [])
for value in at.attributeValue:
content = [any.exportToXml() for any in value.any]
content = ''.join(content)
values.append(content.decode('utf8'))
attributes.update({
'issuer': name_id.nameQualifier or login.remoteProviderId,
'name_id_content': name_id.content,
'name_id_format': name_id.format,
})
authn_statement = login.assertion.authnStatement[0]
if authn_statement.authnInstant:
attributes['authn_instant'] = utils.iso8601_to_datetime(authn_statement.authnInstant)
if authn_statement.sessionNotOnOrAfter:
attributes['session_not_on_or_after'] = utils.iso8601_to_datetime(authn_statement.sessionNotOnOrAfter)
if authn_statement.sessionIndex:
attributes['session_index'] = authn_statement.sessionIndex
attributes['authn_context_class_ref'] = ()
if authn_statement.authnContext:
authn_context = authn_statement.authnContext
if authn_context.authnContextClassRef:
attributes['authn_context_class_ref'] = \
authn_context.authnContextClassRef
log.debug('trying to authenticate with attributes %r', attributes)
user = auth.authenticate(saml_attributes=attributes)
if user is not None:
if user.is_active:
auth.login(request, user)
request.session['mellon_session'] = utils.flatten_datetime(attributes)
if 'session_not_on_or_after' in attributes:
request.session.set_expiry(attributes['session_not_on_or_after'])
else:
return render(request, 'mellon/inactive_user.html', {
'user': user,
'saml_attributes': attributes})
else:
return render(request, 'mellon/user_not_found.html', {
'saml_attributes': attributes })
next_url = login.msgRelayState or settings.LOGIN_REDIRECT_URL
return HttpResponseRedirect(next_url)
def get(self, request, *args, **kwargs):
'''Initialize login request'''
next_url = request.GET.get('next')
idp = self.get_idp(request)
if idp is None:
return HttpResponseBadRequest('unkown entity_id')
login = utils.create_login(request)
log.debug('authenticating to %r', idp['ENTITY_ID'])
try:
login.initAuthnRequest(idp['ENTITY_ID'],
lasso.HTTP_METHOD_REDIRECT)
authn_request = login.request
# configure NameID policy
policy = authn_request.nameIdPolicy
policy_format = idp.get('NAME_ID_POLICY_FORMAT') or app_settings.NAME_ID_POLICY_FORMAT
policy.format = policy_format or None
force_authn = idp.get('FORCE_AUTHN') or app_settings.FORCE_AUTHN
if force_authn:
policy.forceAuthn = True
if request.GET.get('passive') == '1':
policy.isPassive = True
# configure requested AuthnClassRef
authn_classref = idp.get('AUTHN_CLASSREF') or app_settings.AUTHN_CLASSREF
if authn_classref:
req_authncontext = lasso.RequestedAuthnContext()
authn_request.requestedAuthnContext = req_authncontext
req_authncontext.authnContextClassRef = authn_classref
if next_url:
login.msgRelayState = next_url
login.buildAuthnRequestMsg()
except lasso.Error, e:
return HttpResponseBadRequest('error initializing the '
'authentication request: %r' % e)
log.debug('sending authn request %r', authn_request.dump())
log.debug('to url %r', login.msgUrl)
return HttpResponseRedirect(login.msgUrl)
login = csrf_exempt(LoginView.as_view())
class LogoutView(View):
pass
logout = LogoutView.as_view()
def metadata(request):
metadata = utils.create_metadata(request)
return HttpResponse(metadata, content_type='text/xml')

101
setup.py Executable file
View File

@ -0,0 +1,101 @@
#! /usr/bin/env python
''' Setup script for mellon
'''
from setuptools import setup, find_packages
from setuptools.command.install_lib import install_lib as _install_lib
from distutils.command.build import build as _build
from distutils.command.sdist import sdist as _sdist
from distutils.cmd import Command
class compile_translations(Command):
description = 'compile message catalogs to MO files via django compilemessages'
user_options = []
def initialize_options(self):
pass
def finalize_options(self):
pass
def run(self):
import os
import sys
from django.core.management.commands.compilemessages import \
compile_messages
for path in ['mellon/']:
if path.endswith('.py'):
continue
curdir = os.getcwd()
os.chdir(os.path.realpath(path))
compile_messages(sys.stderr)
os.chdir(curdir)
class build(_build):
sub_commands = [('compile_translations', None)] + _build.sub_commands
class sdist(_sdist):
sub_commands = [('compile_translations', None)] + _sdist.sub_commands
class install_lib(_install_lib):
def run(self):
self.run_command('compile_translations')
_install_lib.run(self)
def get_version():
import glob
import re
import os
version = None
for d in glob.glob('*'):
if not os.path.isdir(d):
continue
module_file = os.path.join(d, '__init__.py')
if not os.path.exists(module_file):
continue
for v in re.findall("""__version__ *= *['"](.*)['"]""",
open(module_file).read()):
assert version is None
version = v
if version:
break
assert version is not None
if os.path.exists('.git'):
import subprocess
p = subprocess.Popen(['git','describe','--dirty','--match=v*'],
stdout=subprocess.PIPE)
result = p.communicate()[0]
assert p.returncode == 0, 'git returned non-zero'
new_version = result.split()[0][1:]
assert new_version.split('-')[0] == version, '__version__ must match the last git annotated tag'
version = new_version.replace('-', '.')
return version
setup(name="django-mellon",
version=get_version(),
license="AGPLv3 or later",
description="SAML 2.0 authentication for Django",
long_description=file('README').read(),
url="http://dev.entrouvert.org/projects/django-mellon/",
author="Entr'ouvert",
author_email="info@entrouvert.org",
include_package_data=True,
packages=find_packages(),
install_requires=[],
setup_requires=[
'django>=1.6',
],
tests_require=[
'nose>=0.11.4',
],
dependency_links=[],
cmdclass={
'build': build,
'install_lib': install_lib,
'compile_translations': compile_translations,
'sdist': sdist,
},
)