From a1646489e2c1d3c7f62a745edfd52b8d2872ca43 Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Mon, 28 Apr 2014 14:33:04 +0200 Subject: [PATCH] first commit --- COPYING | 2 + README | 217 ++++++++++++++++++++ mellon/__init__.py | 1 + mellon/adapters.py | 112 ++++++++++ mellon/admin.py | 3 + mellon/app_settings.py | 45 ++++ mellon/backends.py | 27 +++ mellon/locale/fr/LC_MESSAGES/django.po | 13 ++ mellon/migrations/__init__.py | 0 mellon/models.py | 3 + mellon/static/mellon/.keep | 0 mellon/templates/mellon/.keep | 0 mellon/templates/mellon/inactive_user.html | 10 + mellon/templates/mellon/metadata.xml | 37 ++++ mellon/templates/mellon/user_not_found.html | 11 + mellon/tests.py | 3 + mellon/urls.py | 12 ++ mellon/utils.py | 113 ++++++++++ mellon/views.py | 129 ++++++++++++ setup.py | 101 +++++++++ 20 files changed, 839 insertions(+) create mode 100644 COPYING create mode 100644 README create mode 100644 mellon/__init__.py create mode 100644 mellon/adapters.py create mode 100644 mellon/admin.py create mode 100644 mellon/app_settings.py create mode 100644 mellon/backends.py create mode 100644 mellon/locale/fr/LC_MESSAGES/django.po create mode 100644 mellon/migrations/__init__.py create mode 100644 mellon/models.py create mode 100644 mellon/static/mellon/.keep create mode 100644 mellon/templates/mellon/.keep create mode 100644 mellon/templates/mellon/inactive_user.html create mode 100644 mellon/templates/mellon/metadata.xml create mode 100644 mellon/templates/mellon/user_not_found.html create mode 100644 mellon/tests.py create mode 100644 mellon/urls.py create mode 100644 mellon/utils.py create mode 100644 mellon/views.py create mode 100755 setup.py diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..063668f --- /dev/null +++ b/COPYING @@ -0,0 +1,2 @@ +{{ project_name }} is entirely under the copyright of Entr'ouvert and distributed +under the license AGPLv3 or later. diff --git a/README b/README new file mode 100644 index 0000000..9c2f825 --- /dev/null +++ b/README @@ -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. diff --git a/mellon/__init__.py b/mellon/__init__.py new file mode 100644 index 0000000..1f356cc --- /dev/null +++ b/mellon/__init__.py @@ -0,0 +1 @@ +__version__ = '1.0.0' diff --git a/mellon/adapters.py b/mellon/adapters.py new file mode 100644 index 0000000..af07541 --- /dev/null +++ b/mellon/adapters.py @@ -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 diff --git a/mellon/admin.py b/mellon/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/mellon/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/mellon/app_settings.py b/mellon/app_settings.py new file mode 100644 index 0000000..ed071e4 --- /dev/null +++ b/mellon/app_settings.py @@ -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 diff --git a/mellon/backends.py b/mellon/backends.py new file mode 100644 index 0000000..7056975 --- /dev/null +++ b/mellon/backends.py @@ -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 diff --git a/mellon/locale/fr/LC_MESSAGES/django.po b/mellon/locale/fr/LC_MESSAGES/django.po new file mode 100644 index 0000000..f7cf80d --- /dev/null +++ b/mellon/locale/fr/LC_MESSAGES/django.po @@ -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" diff --git a/mellon/migrations/__init__.py b/mellon/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mellon/models.py b/mellon/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/mellon/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/mellon/static/mellon/.keep b/mellon/static/mellon/.keep new file mode 100644 index 0000000..e69de29 diff --git a/mellon/templates/mellon/.keep b/mellon/templates/mellon/.keep new file mode 100644 index 0000000..e69de29 diff --git a/mellon/templates/mellon/inactive_user.html b/mellon/templates/mellon/inactive_user.html new file mode 100644 index 0000000..7edc81b --- /dev/null +++ b/mellon/templates/mellon/inactive_user.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block content %} +

+ {% blocktrans with name_id=saml_attributes.name_id_content %}Your user, {{ user }}, + is inactive please contact your administrator. + {% endblocktrans %} +

+{% endblock %} diff --git a/mellon/templates/mellon/metadata.xml b/mellon/templates/mellon/metadata.xml new file mode 100644 index 0000000..ee2e652 --- /dev/null +++ b/mellon/templates/mellon/metadata.xml @@ -0,0 +1,37 @@ + + + + {% for public_key in public_keys %} + + + + + {{ public_key }} + + + + + {% endfor %} + + {% for name_id_format in name_id_formats %} + {{ name_id_format }} + {% endfor %} + + + + + diff --git a/mellon/templates/mellon/user_not_found.html b/mellon/templates/mellon/user_not_found.html new file mode 100644 index 0000000..6a34c54 --- /dev/null +++ b/mellon/templates/mellon/user_not_found.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block content %} +

+ {% blocktrans with name_id=saml_attributes.name_id_content %}No user found for NameID {{ name_id }}{% endblocktrans %} +

+
+{{ saml_attributes|pprint }}
+
+{% endblock %} diff --git a/mellon/tests.py b/mellon/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/mellon/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/mellon/urls.py b/mellon/urls.py new file mode 100644 index 0000000..1ed02bb --- /dev/null +++ b/mellon/urls.py @@ -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'), +) diff --git a/mellon/utils.py b/mellon/utils.py new file mode 100644 index 0000000..52d455c --- /dev/null +++ b/mellon/utils.py @@ -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) diff --git a/mellon/views.py b/mellon/views.py new file mode 100644 index 0000000..833f71c --- /dev/null +++ b/mellon/views.py @@ -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') diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..d1c484c --- /dev/null +++ b/setup.py @@ -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, + }, +)