commit 37728a07bc3ca005b83747dca9d841a7e37fc2e0 Author: Benjamin Dauvergne Date: Thu Mar 20 17:36:35 2014 +0100 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a89875 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +MANIFEST +*.pyc +*.pyo +*.db +.*.swp +cache/ +dist/ +static/ +doc/_build +authentic.egg-info +local_settings.py +log.log +authentic2/locale/fr/LC_MESSAGES/django.mo diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..4a143c4 --- /dev/null +++ b/README.txt @@ -0,0 +1,10 @@ +Install +======= + +You just have to install the package in your virtualenv and relaunch, it will +be automatically loaded by the plugin framework. + + +Settings +======== + diff --git a/authentic2_auth_saml2/__init__.py b/authentic2_auth_saml2/__init__.py new file mode 100644 index 0000000..6b24fb6 --- /dev/null +++ b/authentic2_auth_saml2/__init__.py @@ -0,0 +1,38 @@ + +__version__ = '1.0' + +class Plugin(object): + def __init__(self): + from authentic2.decorators import TRANSIENT_USER_TYPES + from . import transient + + TRANSIENT_USER_TYPES.append(transient.SAML2TransientUser) + + def get_before_urls(self): + from . import urls + return urls.urlpatterns + + def get_apps(self): + return [__name__, 'authentic2.saml'] + + def get_authentication_backends(self): + return ( + 'authentic2_auth_saml2.backends.AuthSAML2PersistentBackend', + 'authentic2_auth_saml2.backends.AuthSAML2TransientBackend', + ) + + def get_auth_frontends(self): + return ('authentic2_auth_saml2.frontend.AuthSAML2Frontend',) + + def get_idp_backends(self): + return ('authentic2_auth_saml2.backends.AuthSAML2Backend',) + + def logout_list(self, request): + return [] + + def get_saml2_authn_context(self, backend_cls): + import lasso + + if backend_cls.startswith('authentic2_auth_saml2.'): + return lasso.SAML2_AUTHN_CONTEXT_UNSPECIFIED + return None diff --git a/authentic2_auth_saml2/app_settings.py b/authentic2_auth_saml2/app_settings.py new file mode 100644 index 0000000..29819a6 --- /dev/null +++ b/authentic2_auth_saml2/app_settings.py @@ -0,0 +1,24 @@ +class AppSettings(object): + + def __init__(self, prefix): + self.prefix = prefix + + @property + def AUTOMATIC_GRANT(self): + return self._setting('AUTOMATIC_GRANT', ()) + + def _setting(self, name, dflt): + from django.conf import settings + getter = getattr(settings, + 'ALLAUTH_SETTING_GETTER', + lambda name, dflt: getattr(settings, name, dflt)) + return getter(self.prefix + name, dflt) + + + +# Ugly? Guido recommends this himself ... +# http://mail.python.org/pipermail/python-ideas/2012-May/014969.html +import sys +app_settings = AppSettings('A2_OAUTH2_') +app_settings.__name__ = __name__ +sys.modules[__name__] = app_settings diff --git a/authentic2_auth_saml2/backends.py b/authentic2_auth_saml2/backends.py new file mode 100644 index 0000000..587be2e --- /dev/null +++ b/authentic2_auth_saml2/backends.py @@ -0,0 +1,151 @@ +import string +import random +import logging +import lasso +import urllib + +from django.db import transaction +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext as _ + +from authentic2.compat import get_user_model +from authentic2.saml.common import \ + lookup_federation_by_name_id_and_provider_id, add_federation, \ + get_idp_options_policy +from authentic2.saml.models import LIBERTY_SESSION_DUMP_KIND_SP, \ + LibertySessionDump, LibertyProvider + +from .transient import SAML2TransientUser + + +logger = logging.getLogger(__name__) + + +class AuthSAML2Backend: + def logout_list(self, request): + q = LibertySessionDump. \ + objects.filter(django_session_key=request.session.session_key, + kind=LIBERTY_SESSION_DUMP_KIND_SP) + if not q: + logger.debug('logout_list: no LibertySessionDump found') + return [] + ''' + We deal with a single IdP session + ''' + try: + provider_id = lasso.Session(). \ + newFromDump(q[0].session_dump.encode('utf-8')). \ + get_assertions().keys()[0] + except: + return [] + if not provider_id: + return [] + logger.debug('logout_list: Found session for %s' % provider_id) + name = provider_id + provider = None + try: + provider = LibertyProvider.objects.get(entity_id=provider_id) + name = provider.name + except LibertyProvider.DoesNotExist: + logger.error('logout_list: session found for unknown provider %s' \ + % provider_id) + return [] + + policy = get_idp_options_policy(provider) + if not policy: + logger.error('logout_list: No policy found for %s' % provider_id) + return [] + elif not policy.forward_slo: + logger.info('logout_list: %s configured to not reveive slo' \ + % provider_id) + return [] + else: + code = '
' + code += _('Sending logout to %(pid)s....') % { 'pid': name or provider_id } + query = urllib.urlencode({ + 'entity_id': provider_id + }) + url = '%s?%s' % (reverse('a2-auth-saml2-slo'), query) + code += '''
''' \ + % url + return [ code ] + + +class AuthSAML2PersistentBackend: + supports_object_permissions = False + supports_anonymous_user = False + + def authenticate(self, name_id=None, provider_id=None, create=False): + '''Authenticate persistent NameID''' + if not name_id or not provider_id:# or not name_id.nameQualifier: + return None + #fed = lookup_federation_by_name_identifier(name_id=name_id) + fed = lookup_federation_by_name_id_and_provider_id(name_id, provider_id) + if fed is None: + if create == True: + return self.create_user(name_id=name_id, + provider_id=provider_id) + else: + return None + else: + return fed.user + + def get_user(self, user_id): + User = get_user_model() + try: + return User.objects.get(id=user_id) + except User.DoesNotExist: + return None + + @transaction.commit_on_success + def create_user(self, username=None, name_id=None, provider_id=None): + '''Create a new user mapping to the given NameID''' + if not name_id or \ + name_id.format != \ + lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT or \ + not name_id.nameQualifier: + raise ValueError('Invalid NameID') + if not username: + # FIXME: maybe keep more information in the forged username + username = 'saml2-%s' % ''. \ + join([random.SystemRandom().choice(string.letters) for x in range(10)]) + User = get_user_model() + user = User() + user.username = username + if hasattr(User, 'set_unusable_password'): + user.set_unusable_password() + user.is_active = True + user.save() + logger.info('automatic creation of user %r', user) + add_federation(user, name_id=name_id, provider_id=provider_id) + return user + + @classmethod + def get_saml2_authn_context(cls): + return lasso.SAML2_AUTHN_CONTEXT_UNSPECIFIED + +class AuthSAML2TransientBackend: + supports_object_permissions = False + supports_anonymous_user = False + + def authenticate(self, name_id=None): + '''Create temporary user for transient NameID''' + if not name_id or \ + name_id.format != \ + lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT or \ + not name_id.content: + return None + user = SAML2TransientUser(id=name_id.content) + return user + + def get_user(self, user_id): + '''Create temporary user for transient NameID''' + return SAML2TransientUser(id=user_id) + + @classmethod + def get_saml2_authn_context(cls): + return lasso.SAML2_AUTHN_CONTEXT_UNSPECIFIED + + + diff --git a/authentic2_auth_saml2/decorators.py b/authentic2_auth_saml2/decorators.py new file mode 100644 index 0000000..585d130 --- /dev/null +++ b/authentic2_auth_saml2/decorators.py @@ -0,0 +1,20 @@ +from django.conf import settings +from django.utils.http import urlencode +from django.contrib.auth import REDIRECT_FIELD_NAME +from django.http import HttpResponseRedirect + +from functools import wraps + +def anonymous_only(func): + '''Logout before entering this view''' + @wraps(func) + def f(request, *args, **kwargs): + if request.user.is_authenticated(): + current_url = request.build_absolute_uri() + query = urlencode({REDIRECT_FIELD_NAME: current_url}) + logout_url = '{0}?{1}'.format(settings.LOGOUT_URL, query) + return HttpResponseRedirect(logout_url) + return func(request, *args, **kwargs) + return f + + diff --git a/authentic2_auth_saml2/forms.py b/authentic2_auth_saml2/forms.py new file mode 100644 index 0000000..473cd26 --- /dev/null +++ b/authentic2_auth_saml2/forms.py @@ -0,0 +1,19 @@ +from django import forms +from django.utils.translation import ugettext as _ + + +from authentic2.utils import IterableFactory +from authentic2.saml import models + + +def provider_list_to_choices(qs): + for idp in qs: + yield idp.entity_id, idp.name + +def get_idp_list(): + qs = models.LibertyProvider.objects.idp_enabled().order_by('name') + return provider_list_to_choices(qs) + +class AuthSAML2Form(forms.Form): + entity_id = forms.ChoiceField(label=_('Choose your identity provider'), + choices=IterableFactory(get_idp_list)) diff --git a/authentic2_auth_saml2/frontend.py b/authentic2_auth_saml2/frontend.py new file mode 100644 index 0000000..2ae7009 --- /dev/null +++ b/authentic2_auth_saml2/frontend.py @@ -0,0 +1,42 @@ +import urllib + +from django.utils.translation import gettext_noop +from django.http import HttpResponseRedirect +from django.contrib.auth import REDIRECT_FIELD_NAME + +from authentic2.saml.models import LibertyProvider + +from . import forms, views_profile + +class AuthSAML2Frontend(object): + def enabled(self): + return LibertyProvider.objects.idp_enabled().exists() + + def id(self): + return 'saml2' + + def name(self): + return gettext_noop('SAML 2.0') + + def form(self): + return forms.AuthSAML2Form + + def post(self, request, form, nonce, next): + entity_id = form.cleaned_data['entity_id'] + query = urllib.urlencode({ + 'entity_id': entity_id, + REDIRECT_FIELD_NAME: next + }) + return HttpResponseRedirect('/authsaml2/sso?%s' % query) + + def get_context(self): + '''Specific context variable used by the specific template''' + qs = LibertyProvider.objects.idp_enabled().order_by('name') + choices = forms.provider_list_to_choices(qs) + return { 'idp_providers': choices } + + def template(self): + return 'authsaml2/login_form.html' + + def profile(self, request): + return views_profile.profile(request) diff --git a/authentic2_auth_saml2/locale/fr/LC_MESSAGES/django.po b/authentic2_auth_saml2/locale/fr/LC_MESSAGES/django.po new file mode 100644 index 0000000..dbcc035 --- /dev/null +++ b/authentic2_auth_saml2/locale/fr/LC_MESSAGES/django.po @@ -0,0 +1,339 @@ +# French translation of Authentic +# Copyright (C) 2010, 2011 Entr'ouvert +# This file is distributed under the same license as the Authentic package. +# Frederic Peters , 2010. +# +msgid "" +msgstr "" +"Project-Id-Version: Authentic\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2014-02-27 16:42+0100\n" +"PO-Revision-Date: 2014-02-27 16:42+0100\n" +"Last-Translator: Mikaël Ates \n" +"Language-Team: None\n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n>1;\n" + +#: backends.py:64 +#, python-format +msgid "Sending logout to %(pid)s...." +msgstr "Envoi de la deconnesion a %(pid)s...." + +#: forms.py:16 +msgid "Choose your identity provider" +msgstr "Choisissez votre fournisseur d'identité" + +#: frontend.py:19 +msgid "SAML 2.0" +msgstr "SAML 2.0" + +#: models.py:91 +msgid "Anonymous" +msgstr "Anonyme" + +#: saml2_endpoints.py:119 saml2_endpoints.py:125 +msgid "sso: Service provider not configured" +msgstr "" + +#: saml2_endpoints.py:131 +msgid "sso: No SAML2 identity provider selected" +msgstr "" + +#: saml2_endpoints.py:139 +msgid "sso: The provider does not exist" +msgstr "" + +#: saml2_endpoints.py:144 +msgid "sso: Unable to create Login object" +msgstr "" + +#: saml2_endpoints.py:153 +#, python-format +msgid "sso: %s does not have any supported SingleSignOn endpoint" +msgstr "" + +#: saml2_endpoints.py:159 +#, python-format +msgid "sso: initAuthnRequest %s" +msgstr "" + +#: saml2_endpoints.py:165 +msgid "sso: No IdP policy defined" +msgstr "" + +#: saml2_endpoints.py:171 +#, python-format +msgid "SSO: buildAuthnRequestMsg %s" +msgstr "" + +#: saml2_endpoints.py:198 +msgid "singleSignOnArtifact: Service provider not configured" +msgstr "" + +#: saml2_endpoints.py:216 +msgid "singleSignOnArtifact: Unable to create Login object" +msgstr "" + +#: saml2_endpoints.py:222 +msgid "singleSignOnArtifact: No message given." +msgstr "" + +#: saml2_endpoints.py:243 +#, python-format +msgid "singleSignOnArtifact: provider %r unknown" +msgstr "" + +#: saml2_endpoints.py:252 +#, python-format +msgid "singleSignOnArtifact: initRequest %s" +msgstr "" + +#: saml2_endpoints.py:260 +#, python-format +msgid "singleSignOnArtifact: buildRequestMsg %s" +msgstr "" + +#: saml2_endpoints.py:272 +#, python-format +msgid "" +"singleSignOnArtifact: Failure to communicate with artifact " +"resolver %r" +msgstr "" + +#: saml2_endpoints.py:277 +#, python-format +msgid "" +"singleSignOnArtifact: Artifact resolver at %r returned an empty " +"response" +msgstr "" + +#: saml2_endpoints.py:293 +#, python-format +msgid "singleSignOnArtifact: processResponseMsg raised %s" +msgstr "" + +#: saml2_endpoints.py:308 +msgid "singleSignOnPost: Service provider not configured" +msgstr "" + +#: saml2_endpoints.py:314 +msgid "singleSignOnPost: Unable to create Login object" +msgstr "" + +#: saml2_endpoints.py:323 +msgid "singleSignOnPost: No message given." +msgstr "" + +#: saml2_endpoints.py:350 +#, python-format +msgid "singleSignOnPost: provider %r unknown" +msgstr "" + +#: saml2_endpoints.py:361 +#, python-format +msgid "singleSignOnPost: %s" +msgstr "" + +#: saml2_endpoints.py:384 +msgid "sso_after_response: error checking authn response" +msgstr "" + +#: saml2_endpoints.py:390 +#, python-format +msgid "sso_after_response: acceptSso raised %s" +msgstr "" + +#: saml2_endpoints.py:526 +msgid "sso_after_response: No IdP policy defined" +msgstr "" + +#: saml2_endpoints.py:575 +msgid "" +"sso_after_response: No backend for temporary federation " +"is configured" +msgstr "" + +#: saml2_endpoints.py:596 +msgid "" +"sso_after_response: Transient access policy: Configuration error" +msgstr "" + +#: saml2_endpoints.py:672 +msgid "" +"sso_after_response: You were not asked your consent for " +"account linking" +msgstr "" + +#: saml2_endpoints.py:689 +msgid "" +"sso_after_response: Persistent Account policy: Configuration " +"error" +msgstr "" + +#: saml2_endpoints.py:693 +msgid "" +"sso_after_response: Transient access policy: NameId format not " +"supported" +msgstr "" + +#: saml2_endpoints.py:716 +msgid "finish_federation: Service provider not configured" +msgstr "" + +#: saml2_endpoints.py:722 +msgid "finish_federation: Unable to create Login object" +msgstr "" + +#: saml2_endpoints.py:729 +msgid "finish_federation: Error loading session." +msgstr "" + +#: saml2_endpoints.py:745 +msgid "" +"SSO/finish_federation: Error adding new federation for " +"this user" +msgstr "" + +#: saml2_endpoints.py:784 +msgid "finish_federation: Unable to perform federation" +msgstr "" + +#: saml2_endpoints.py:1250 +msgid "fedTerm/SP UI: No provider for defederation" +msgstr "" + +#: saml2_endpoints.py:1255 +msgid "fedTerm/SP UI: Unable to defederate a not logged user!" +msgstr "" + +#: saml2_endpoints.py:1261 +msgid "fedTerm/SP UI: Service provider not configured" +msgstr "" + +#: saml2_endpoints.py:1268 +msgid "fedTerm/SP UI: No such identity provider." +msgstr "" + +#: saml2_endpoints.py:1278 +msgid "fedTerm/SP UI: Not a valid federation" +msgstr "" + +#: saml2_endpoints.py:1294 +#, python-format +msgid "fedTerm/SP UI: %s" +msgstr "" + +#: saml2_endpoints.py:1302 saml2_endpoints.py:1333 +#, python-format +msgid "fedTerm/SP SOAP: %s" +msgstr "" + +#: saml2_endpoints.py:1311 +msgid "" +"fedTerm/SP SOAP: Unable to perform SOAP defederation " +"request" +msgstr "" + +#: saml2_endpoints.py:1320 saml2_endpoints.py:1354 +#, python-format +msgid "fedTerm/SP Redirect: %s" +msgstr "" + +#: saml2_endpoints.py:1342 +msgid "" +"fedTerm/SP SOAP: Unable to perform SOAP defederation request" +msgstr "" + +#: saml2_endpoints.py:1359 +msgid "Unknown HTTP method." +msgstr "" + +#: saml2_endpoints.py:1372 +msgid "fedTerm/SP Redirect: Service provider not configured" +msgstr "" + +#: saml2_endpoints.py:1380 +msgid "fedTerm/SP Redirect: Error managing manage dump" +msgstr "" + +#: saml2_endpoints.py:1395 +msgid "fedTerm/SP Redirect: Defederation failed" +msgstr "" + +#: saml2_endpoints.py:1421 +#, python-format +msgid "fedTerm/Return: provider %r unknown" +msgstr "" + +#: saml2_endpoints.py:1428 +#, python-format +msgid "fedTerm/manage_name_id_return: %s" +msgstr "" + +#: saml2_endpoints.py:1476 +#, python-format +msgid "fedTerm/SOAP: provider %r unknown" +msgstr "" + +#: utils.py:42 +#, python-format +msgid "An error happened. Report this %s to the administrator." +msgstr "" + +#: views_disco.py:53 +msgid "redirect_to_disco: unable to build disco request" +msgstr "" + +#: views_disco.py:63 +msgid "HTTP request not supported" +msgstr "" + +#: views_profile.py:52 +msgid "Successful federation deletion." +msgstr "" + +#: templates/authsaml2/account_linking.html:5 +msgid "Log in to link your account" +msgstr "Connectez-vous pour lier vos comptes" + +#: templates/authsaml2/account_linking.html:9 +msgid "Log in to link with your existing account" +msgstr "Connectez-vous pour lier avec un compte existant" + +#: templates/authsaml2/account_linking.html:17 +#: templates/authsaml2/account_linking.html:24 +msgid "Username:" +msgstr "Nom d'utilisateur :" + +#: templates/authsaml2/account_linking.html:20 +#: templates/authsaml2/account_linking.html:28 +msgid "Password:" +msgstr "Mot de passe :" + +#: templates/authsaml2/account_linking.html:32 +#: templates/authsaml2/login_form.html:6 templates/authsaml2/profile.html:25 +msgid "Log in" +msgstr "S'identifier" + +#: templates/authsaml2/error_authsaml2.html:8 +msgid "Back" +msgstr "Retour" + +#: templates/authsaml2/profile.html:3 +msgid "SAML2 Federations" +msgstr "Fédérations SAML2" + +#: templates/authsaml2/profile.html:9 +msgid "Delete a federation?" +msgstr "Supprimer une fédération ?" + +#: templates/authsaml2/profile.html:13 +msgid "Delete" +msgstr "Supprimer" + +#: templates/authsaml2/profile.html:21 +msgid "Add a federation?" +msgstr "Ajouter une fédération ?" diff --git a/authentic2_auth_saml2/migrations/__init__.py b/authentic2_auth_saml2/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/authentic2_auth_saml2/signals.py b/authentic2_auth_saml2/signals.py new file mode 100644 index 0000000..6544e81 --- /dev/null +++ b/authentic2_auth_saml2/signals.py @@ -0,0 +1,17 @@ +from django.dispatch import Signal + +#authz_decision +authz_decision = Signal(providing_args = ["request","attributes","provider"]) + +#user login +auth_login = Signal(providing_args = ["request","attributes"]) + +#user logout +auth_logout = Signal(providing_args = ["user"]) + +from authentic2.saml.common import authz_decision_cb + +authz_decision.connect(authz_decision_cb, + dispatch_uid='authz_decision_on_attributes') + + diff --git a/authentic2_auth_saml2/templates/authsaml2/account_linking.html b/authentic2_auth_saml2/templates/authsaml2/account_linking.html new file mode 100644 index 0000000..bb9e18f --- /dev/null +++ b/authentic2_auth_saml2/templates/authsaml2/account_linking.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block title %} +{% trans "Log in to link your account" %} +{% endblock %} + +{% block content %} +

* {% trans "Log in to link with your existing account" %}

+
+
+ {% csrf_token %} +
    + {% for error in form.non_field_errors %} +
  • {{ error|escape }}
  • + {% endfor %} + {% for error in form.username.errors %} +
  • {% trans "Username:" %} {{ error|escape }}
  • + {% endfor %} + {% for error in form.password.errors %} +
  • {% trans "Password:" %} {{ error|escape }}
  • + {% endfor %} +
+

+ + +

+

+ + +

+ + + +
+
+ +{% endblock %} diff --git a/authentic2_auth_saml2/templates/authsaml2/error_authsaml2.html b/authentic2_auth_saml2/templates/authsaml2/error_authsaml2.html new file mode 100644 index 0000000..89f7aa0 --- /dev/null +++ b/authentic2_auth_saml2/templates/authsaml2/error_authsaml2.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% load i18n %} +{% block bodyargs %}onload="setTimeout(function () { window.location='{{ next_page }}' }, {{ redir_timeout }})"{% endblock %} + +{% block content %} +

{{ title }}

+ +

{% trans "Back" %}

+{% endblock %} diff --git a/authentic2_auth_saml2/templates/authsaml2/login_form.html b/authentic2_auth_saml2/templates/authsaml2/login_form.html new file mode 100644 index 0000000..21c5831 --- /dev/null +++ b/authentic2_auth_saml2/templates/authsaml2/login_form.html @@ -0,0 +1,8 @@ +{% load i18n %} +
+
+{% csrf_token %} +{{ form.as_p }} + +
+
diff --git a/authentic2_auth_saml2/templates/authsaml2/profile.html b/authentic2_auth_saml2/templates/authsaml2/profile.html new file mode 100644 index 0000000..eb6ed5e --- /dev/null +++ b/authentic2_auth_saml2/templates/authsaml2/profile.html @@ -0,0 +1,30 @@ +{% load i18n %} +{% if form or federations %} +

{% trans "SAML2 Federations" %}

+ +
+ + {% if linked_providers %} +

+

{% trans "Delete a federation?" %}
+ {% for provider_id, name in linked_providers %} +
+ + +
+ {% endfor %} +

+ {% endif %} + + {% if form %} +

+

{% trans "Add a federation?" %}
+
+ {% csrf_token %} + {{ form.as_p }} + +
+

+ {% endif %} +
+{% endif %} diff --git a/authentic2_auth_saml2/templates/rest_framework/api.html b/authentic2_auth_saml2/templates/rest_framework/api.html new file mode 100644 index 0000000..9c44916 --- /dev/null +++ b/authentic2_auth_saml2/templates/rest_framework/api.html @@ -0,0 +1 @@ +{% extends "rest_framework/base.html" %} diff --git a/authentic2_auth_saml2/transient.py b/authentic2_auth_saml2/transient.py new file mode 100644 index 0000000..24672e9 --- /dev/null +++ b/authentic2_auth_saml2/transient.py @@ -0,0 +1,92 @@ +from django.db import models +from django.utils.translation import ugettext_lazy as _ +from django.db.models.manager import EmptyManager +from django.contrib.auth.models import _user_get_all_permissions, _user_has_perm, _user_has_module_perms + + +class FakePk: + name = 'pk' + +class FakeMeta: + pk = FakePk() + +class SAML2TransientUser(object): + '''Class compatible with django.contrib.auth.models.User + which represent an user authenticated using a Transient + federation''' + id = None + pk = None + is_staff = False + is_active = False + is_superuser = False + _groups = EmptyManager() + _user_permissions = EmptyManager() + _meta = FakeMeta() + + def __init__(self, id): + self.id = id + self.pk = id + + def __unicode__(self): + return 'AnonymousUser' + + def __str__(self): + return unicode(self).encode('utf-8') + + def __eq__(self, other): + return isinstance(other, self.__class__) + + def __ne__(self, other): + return not self.__eq__(other) + + def __hash__(self): + return 1 # instances always return the same hash value + + def save(self, **kwargs): + pass + + def delete(self): + raise NotImplementedError + + def set_password(self, raw_password): + raise NotImplementedError + + def check_password(self, raw_password): + raise NotImplementedError + + def _get_groups(self): + return self._groups + groups = property(_get_groups) + + def _get_user_permissions(self): + return self._user_permissions + user_permissions = property(_get_user_permissions) + + def get_group_permissions(self, obj=None): + return set() + + def get_all_permissions(self, obj=None): + return _user_get_all_permissions(self, obj=obj) + + def has_perm(self, perm, obj=None): + return _user_has_perm(self, perm, obj=obj) + + def has_perms(self, perm_list, obj=None): + for perm in perm_list: + if not self.has_perm(perm, obj): + return False + return True + + def has_module_perms(self, module): + return _user_has_module_perms(self, module) + + def is_anonymous(self): + #XXX: Should return True + return False + + def is_authenticated(self): + return True + + def get_username(self): + return _('Anonymous') + username = property(get_username) diff --git a/authentic2_auth_saml2/urls.py b/authentic2_auth_saml2/urls.py new file mode 100644 index 0000000..0a9c240 --- /dev/null +++ b/authentic2_auth_saml2/urls.py @@ -0,0 +1,45 @@ +from django.conf.urls import patterns, url + +urlpatterns = patterns('authentic2.authsaml2.views', + url(r'^metadata$', 'metadata'), + # Receive request from user interface + url(r'^sso/$', 'sso', name='a2-auth-saml2-sso'), + url(r'^account-linking/(?P.*)/$', 'account_linking', + name='a2-auth-saml2-account-linking'), + url(r'^singleSignOnArtifact$', 'assertion_consumer_artifact'), + url(r'^singleSignOnPost$', 'assertion_consumer_post'), + # Receive request from functions + url(r'^sp_slo/$', 'sp_slo', name='a2-auth-saml2-slo'), + # Receive response from Redirect SP initiated + url(r'^singleLogoutReturn$', 'slo_sp_response'), + # Receive request from SOAP IdP initiated + url(r'^singleLogoutSOAP$', 'slo_soap'), + # Receive request from Redirect IdP initiated + url(r'^singleLogout$', 'singleLogout'), + # Back of SLO treatment by the IdP Side + url(r'^finish_slo$', 'finish_slo', + name='a2-auth-saml2-finish-slo'), + # Receive request from user interface + url(r'^federationTermination$', 'federationTermination'), + # Receive response from Redirect SP initiated + url(r'^manageNameIdReturn$', 'manageNameIdReturn'), + # Receive request from SOAP IdP initiated + url(r'^manageNameIdSOAP$', 'manageNameIdSOAP'), + # Receive request from Redirect IdP initiated + url(r'^manageNameId$', 'manageNameId'), + # Receive request from Redirect IdP initiated + #Send idp discovery request +) + +urlpatterns += patterns('authentic2.authsaml2.views_disco', + url(r'^redirect_to_disco/$', 'redirect_to_disco', + name='a2-auth-saml2-redirect-to-disco'), + #receive idp discovery response + url(r'^discoveryReturn/$', 'disco_response', + name='a2-auth-saml2-disco-response'), +) + +urlpatterns += patterns('authentic2.authsaml2.views_profile', + url(r'^delete_federation/(?P\d+)/$', 'delete_federation', + name='a2-auth-saml2-delete-federation'), +) diff --git a/authentic2_auth_saml2/utils.py b/authentic2_auth_saml2/utils.py new file mode 100644 index 0000000..ba0f5fc --- /dev/null +++ b/authentic2_auth_saml2/utils.py @@ -0,0 +1,148 @@ +import re +import time +import logging + +from django.template import RequestContext +from django.contrib.auth import REDIRECT_FIELD_NAME +from django.utils.translation import ugettext as _ +from django.shortcuts import render_to_response +from django.contrib import messages +from django.conf import settings + +from authentic2.saml.models import (nameid2kwargs, LibertySession, + LibertyFederation) + +__redirection_timeout = 1600 + +__root_refererer_re = re.compile('^(https?://[^/]*/?)') + +logger = logging.getLogger(__name__) + +def error_page(request, message=None, back=None, logger=None, + default_message=True, timer=False): + '''View that show a simple error page to the user with a back link. + + back - url for the back link, if None, return to root of the referer + or the local root. + ''' + if logger: + logger.debug('Showing message %r on an error page' % message) + else: + logging.debug('Showing message %r on an error page' % message) + if back is None: + referer = request.META.get('HTTP_REFERER') + if referer: + root_referer = __root_refererer_re.match(referer) + if root_referer: + back = root_referer.group(1) + if back is None: + back = '/' + global __redirection_timeout + context = RequestContext(request) + if timer: + context['redir_timeout'] = __redirection_timeout + context['next_page'] = back + display_message = getattr(settings, 'DISPLAY_MESSAGE_ERROR_PAGE', ()) + if default_message and not display_message: + messages.add_message(request, messages.ERROR, + _('An error happened. Report this %s to the administrator.') % \ + time.strftime("[%Y-%m-%d %a %H:%M:%S]", time.localtime())) + elif message: + messages.add_message(request, messages.ERROR, message) + return render_to_response('authsaml2/error_authsaml2.html', {'back': back}, + context_instance=context) + +# Used to register requested url during SAML redirections +def register_next_target(request, url=None): + if url: + next = url + else: + next = request.GET.get(REDIRECT_FIELD_NAME) + if not next: + next = '/' + request.session['next'] = next + logger.debug('saving next target %r', next) + +def get_registered_url(request): + if 'next' in request.session: + return request.session['next'] + return None + +def register_request_id(request, request_id): + request.session['saml_request_id'] = request_id + +# Used for account linking +def save_federation_temp(request, login, attributes): + if login and login.identity: + request.session['identity_dump'] = login.identity.dump() + request.session['remoteProviderId'] = login.remoteProviderId + request.session['nameId'] = login.nameIdentifier + request.session['attributes'] = attributes + +def load_federation_temp(request, login): + if 'identity_dump' in request.session: + login.setIdentityFromDump(request.session['identity_dump']) + +def save_login_session(request, login): + try: + session_index = login.response.assertion[0].authnStatement[0].sessionIndex + except (AttributeError, IndexError): + return + LibertySession.objects.get_or_create( + django_session_key=request.session.session_key, + session_index=session_index, + provider_id=login.remoteProviderId, + **nameid2kwargs(login.nameIdentifier)) + +def logout_session(session_key, entity_id): + qs = LibertySession.objects.filter( + django_session_key=session_key) + qs = qs.filter(idp__entity_id=entity_id) + return qs + +def kill_logout_session(session_key, entity_id): + qs = logout_session(session_key, entity_id) + qs.delete() + +def load_logout_session(logout, session_key, entity_id): + qs = LibertySession.objects.filter( + django_session_key=session_key) + qs = qs.filter(idp__entity_id=entity_id) + assert qs.exists(), 'no session found for session_key %r and entity_id %r' % \ + (session_key, entity_id) + logout.setSessionDump(qs.to_session_dump()) + +def has_federation(user, entity_id): + return LibertyFederation.objects.filter(user=user, + sp__entity_id=entity_id).exists() + +def kill_federations(user, entity_id): + LibertyFederation.objects.filter(user=user, + sp__entity_id=entity_id).update(user=None) + +def get_sessions(name_id, session_indexes=None): + qs = LibertySession.objects.filter( + **nameid2kwargs(name_id)) + if session_indexes: + qs.filter(session_index__in=session_indexes) + return qs + +def has_sessions(name_id, session_indexes=None): + return get_sessions(name_id, session_indexes).exists() + +def get_session_keys(sessions): + return sessions.values_list('django_session_key', flat=True) + +def get_django_session_key_for_session_index(session_index) + ls = LibertySession.objects.get(session_index=session_index) + return ls.django_session_key + +def can_do_synchronous_logout(sessions): + django_session_keys = get_session_keys(sessions) + # FIXME: we should check every auth and idp for synchronous logout + if LibertySession.objects.filter( + django_session__in=django_session_keys, + idp__isnull=False).exists(): + return False + return True + diff --git a/authentic2_auth_saml2/views.py b/authentic2_auth_saml2/views.py new file mode 100644 index 0000000..c1d1d9d --- /dev/null +++ b/authentic2_auth_saml2/views.py @@ -0,0 +1,742 @@ +"""SAML 2.0 SP implementation""" + +import logging, operator + +import lasso + +import authentic2.idp.views as idp_views + +from django.conf import settings +from django.shortcuts import render, redirect +from django.core.urlresolvers import reverse +from django.http import HttpResponse, HttpResponseRedirect, \ + HttpResponseBadRequest, HttpResponseForbidden +from django.views.decorators.csrf import csrf_exempt +from django.contrib.auth import (login as auth_login, + REDIRECT_FIELD_NAME, authenticate) +from django.contrib import messages +from django.utils.translation import ugettext as _ +from django.utils.http import urlencode +from django.core.cache import cache +from django.contrib.auth.decorators import login_required + +from authentic2.saml.common import (load_provider, + return_saml2_response, return_saml2_request, + get_saml2_query_request, get_saml2_post_response, soap_call, + get_authorization_policy, get_idp_options_policy, + add_federation, send_soap_request, get_soap_message, + get_saml2_metadata, create_saml2_server, + maintain_liberty_session_on_service_provider, + get_session_not_on_or_after, + AUTHENTIC_STATUS_CODE_UNKNOWN_PROVIDER, + AUTHENTIC_STATUS_CODE_INTERNAL_SERVER_ERROR, + AUTHENTIC_STATUS_CODE_UNAUTHORIZED) +from authentic2.saml.models import (nameid2kwargs, LibertyProvider, + save_key_values, NAME_ID_FORMATS, get_and_delete_key_values) +from authentic2.saml.saml2utils import (authnresponse_checking, + get_attributes_from_assertion) +from authentic2.idp.saml.saml2_endpoints import return_logout_error +from authentic2.authsaml2.utils import error_page +from authentic2.authsaml2 import signals +from authentic2.utils import cache_and_validate, flush_django_session + +from . import utils +from .decorators import anonymous_only + +__logout_redirection_timeout = getattr(settings, + 'IDP_LOGOUT_TIMEOUT', 600) + +logger = logging.getLogger(__name__) + +MANAGE_DUMP_KEY = 'manage-dump' +CURRENT_IDP = 'current-idp' + +metadata_map = ( + ('AssertionConsumerService', + lasso.SAML2_METADATA_BINDING_ARTIFACT, + '/singleSignOnArtifact'), + ('AssertionConsumerService', + lasso.SAML2_METADATA_BINDING_POST, + '/singleSignOnPost'), + ('SingleLogoutService', + lasso.SAML2_METADATA_BINDING_REDIRECT, + '/singleLogout', '/singleLogoutReturn'), + ('SingleLogoutService', + lasso.SAML2_METADATA_BINDING_SOAP, + '/singleLogoutSOAP'), + ('ManageNameIDService', + lasso.SAML2_METADATA_BINDING_SOAP, + '/manageNameIdSOAP'), + ('ManageNameIDService', + lasso.SAML2_METADATA_BINDING_REDIRECT, + '/manageNameId', '/manageNameIdReturn'), +) +metadata_options = {'key': settings.SAML_SIGNATURE_PUBLIC_KEY} +try: + if settings.SHOW_DISCO_IN_MD: + metadata_options['disco'] = ('/discoveryReturn', ) +except: + pass + +@cache_and_validate(settings.LOCAL_METADATA_CACHE_TIMEOUT) +def metadata(request): + '''Endpoint to retrieve the metadata file''' + return HttpResponse(get_metadata(request, request.path), + mimetype='text/xml') + +HTTP_METHODS = { + 'POST': lasso.HTTP_METHOD_POST, + 'REDIRECT': lasso.HTTP_METHOD_POST, +} + +@anonymous_only +def sso(request): + '''View for initiating a new authnrequest + + Query parameters: + passive - if equal to 1, ask idp not to display any UI to the user + force_authn - if equal to 1, ask idp to reauthenticate the user + ''' + is_passive = request.REQUEST.get('passive') == 1 + force_authn = request.REQUEST.get('force_authn') == 1 + + entity_id = request.REQUEST.get('entity_id') + # 1. Save the target page + next_url = request.REQUEST.get(REDIRECT_FIELD_NAME, + settings.LOGIN_REDIRECT_URL) + + # 2. Init the server object + server = build_service_provider(request) + assert server is not None, 'Service provider not configured' + + # 3. Define the provider or ask the user + if not entity_id: + idps = LibertyProvider.objects.idp_enabled() + assert idps.count() > 1, 'Too much IdP to select one' + assert idps.count() == 1, 'No IdP to select, add one' + entity_id = idps[0].entity_id + logger.info('sso with provider %r', entity_id) + p = load_provider(request, entity_id, server=server, sp_or_idp='idp', + autoload=True) + assert p, 'provider %r not found' % entity_id + # 4. Build authn request + login = lasso.Login(server) + assert login, 'unable to build a LassoLogin object' + # Only redirect is necessary for the authnrequest + http_method = server.getFirstHttpMethod(server.providers[p.entity_id], + lasso.MD_PROTOCOL_TYPE_SINGLE_SIGN_ON) + assert http_method != lasso.HTTP_METHOD_NONE, \ + 'Not HTTP method declared for SSO by %r' % entity_id + try: + login.initAuthnRequest(p.entity_id, http_method) + except lasso.Error, error: + lasso_error(request, 'login.initAuthnRequest', error) + + # 5. Request setting + assert setAuthnrequestOptions(p, login, force_authn, is_passive), \ + 'no idp policy defined for %r' % entity_id + try: + login.buildAuthnRequestMsg() + except lasso.Error, error: + lasso_error(request, 'login.buildAuthnRequestMsg', error) + + # 6. Save the request ID (association with the target page) + logger.debug('RequestID: %r', login.request.iD) + logger.debug('Session Key: %r', request.session.session_key) + request_id = login.request.id + request.session['state-%s' % request_id] = next_url + + # 7. Redirect the user + title = _('Sending request to %s') % p.name + return return_saml2_request(request, login, title) + + +@csrf_exempt +def assertion_consumer_artifact(request): + '''Assertion consumer for the artifact binding''' + if request.method == 'GET': + http_method = lasso.HTTP_METHOD_ARTIFACT_GET + else: + http_method = lasso.HTTP_METHOD_ARTIFACT_POST + server = build_service_provider(request) + + # Load the provider metadata using the artifact + artifact = request.REQUEST.get('SAMLart') + logger.debug('artifact %r', artifact) + qs = LibertyProvider.objects.by_artifact(artifact).filter( + identity_provider__enabled=True) + assert len(qs) == 0, 'unable to resolve artifact %r' % artifact + assert len(qs) > 1, 'too much provider found for artifact %r' % artifact + p = load_provider(request, qs[0].entity_id, server=server, sp_or_idp='idp') + logger.debug('loaded provider %r', p.entity_id) + login = build_login(server) + + try: + login.initRequest(artifact, http_method) + except lasso.Error, e: + lasso_error(request, 'login.initRequest', e) + + try: + login.buildRequestMsg() + except lasso.Error, e: + lasso_error(request, 'login.buildRequestMsg', e) + + soap_answer = soap_call(login.msgUrl, login.msgBody) + + try: + login.processResponseMsg(soap_answer) + except lasso.Error, error: + lasso_error(request, 'login.processResponseMsg', error) + return sso_after_response(request, login, provider=p) + + +@csrf_exempt +def assertion_consumer_post(request): + '''Assertion consumer for the POST binding''' + server = build_service_provider(request) + login = build_login.Login(server) + message = get_saml2_post_response(request) + + while True: + try: + login.processAuthnResponseMsg(message) + break + except (lasso.ServerProviderNotFoundError, + lasso.ProfileUnknownProviderError): + entity_id = login.remoteProviderId + provider_loaded = load_provider(request, entity_id, server=server, + sp_or_idp='idp') + assert provider_loaded, 'unable to find provider %r' % entity_id + except lasso.Error, e: + lasso_error(request, 'login.processAuthnResponseMsg', e) + return sso_after_response(request, login, provider=provider_loaded) + + +def sso_after_response(request, login, relay_state=None, + provider=None): + '''Common assertionConsumer processing''' + try: + request_id = login.response.inResponseTo + except AttributeError: + request_id = None + if request_id is not None: + try: + next_url = request.session.get('state-%s' % request_id) + except TypeError: + raise AssertionError('no url stored for request id %r', request_id) + assert next_url, 'missing next_url' + else: + next_url = settings.LOGIN_REDIRECT_URL + subject_confirmation = request.build_absolute_uri().partition('?')[0] + assert authnresponse_checking(login, subject_confirmation, logger, + saml_request_id=request_id), 'authnresponse check failed' + + try: + login.acceptSso() + except lasso.Error, e: + lasso_error(request, 'login.acceptSso', e) + + attributes = get_attributes_from_assertion(login.assertion, logger) + # Register attributes in session for other applications + request.session['attributes'] = attributes + + attrs = {} + + for att_statement in login.assertion.attributeStatement: + for attribute in att_statement.attribute: + name = None + att_format = lasso.SAML2_ATTRIBUTE_NAME_FORMAT_BASIC + nickname = None + name = attribute.name.decode('ascii') + if attribute.nameFormat: + att_format = attribute.nameFormat.decode('ascii') + if attribute.friendlyName: + nickname = attribute.friendlyName + if name: + if att_format: + if nickname: + key = (name, att_format, nickname) + else: + key = (name, att_format) + else: + key = (name) + attrs[key] = list() + for value in attribute.attributeValue: + content = [any.exportToXml() for any in value.any] + content = ''.join(content) + attrs[key].append(content.decode('utf8')) + + entity_id = provider.entity_id + a8n = {} + a8n['certificate_type'] = 'SAML2_assertion' + TRANSFER = ( + ('nameid', + 'subject.nameID.content'), + ('subject_confirmation_method', + 'subject.subjectConfirmation.method'), + ('not_before', + 'subject.subjectConfirmation.subjectConfirmationData.notBefore'), + ('not_on_or_after', + 'subject.subjectConfirmation.subjectConfirmationData.notOnOrAfter'), + ('authn_context', + 'assertion.authnStatement[0].authnContext.authnContextClassRef'), + ('autn_instant', + 'assertion.authnStatement[0].authnInstant'), + ) + for target, attribute in TRANSFER: + try: + a8n[target] = operator.attrgetter(attribute)(login) + except AttributeError: + pass + a8n['attributes'] = attrs + logger.debug('attributes in assertion %r from %r', attrs, entity_id) + + #Access control processing + decisions = signals.authz_decision.send(sender=None, request=request, + attributes=attributes, provider=provider) + if not decisions: + logger.debug('No authorization function connected') + + access_granted = True + one_message = False + for decision in decisions: + logger.debug('authorization function %r', decision[0].__name__) + dic = decision[1] + logger.debug('decision is %r', dic['authz']) + if 'message' in dic: + logger.debug('with message %r', dic['message']) + if not dic['authz']: + access_granted = False + if 'message' in dic: + one_message = True + messages.add_message(request, messages.ERROR, dic['message']) + + if not access_granted: + if not one_message: + p = get_authorization_policy(provider) + messages.add_message(request, messages.ERROR, + p.default_denial_message) + return error_page(request, logger=logger, default_message=False, + timer=True) + + #Access granted, now we deal with session management + policy = get_idp_options_policy(provider) + assert policy, 'missing idp options policy' + + user = request.user + nid = login.nameIdentifier + nid_dump = nid.dump() + logger.debug('nid dump %r', nid_dump) + nid_format = login.nameIdentifier.format + if nid_format == lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT \ + and (policy is None or not policy.transient_is_persistent): + logger.debug('nid is transient') + if policy.handle_transient == 'AUTHSAML2_UNAUTH_TRANSIENT_ASK_AUTH': + if not request.user.is_authenticated(): + response_id = login.response.id + assert response_id, 'missing response id' + request.session['state-%s' % response_id] = \ + login.dump(), attributes, next_url + maintain_liberty_session_on_service_provider(request, login) + return redirect('a2-auth-saml2-account-linking', + kwargs={ 'response_id': login.response.id}) + if request.session.test_cookie_worked(): + request.session.delete_test_cookie() + utils.save_login_session(request, login) + elif policy.handle_transient == 'AUTHSAML2_UNAUTH_TRANSIENT_OPEN_SESSION': + logger.debug('Opening session for transient with nameID') + user = authenticate(name_id=nid) + assert user, 'No backend for temporary federation is configured' + return finish_sso(request, login, user, attributes, next_url) + else: + raise NotImplementedError('unkown policy.handler_transient') + return HttpResponseRedirect(next_url) + else: + logger.debug('persistent federation processing') + if policy is not None and policy.transient_is_persistent and \ + nid_format == lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT: + logger.debug('use transient nid as persistent %r', nid_dump) + if policy.persistent_identifier_attribute: + ppid = policy.persistent_identifier_attribute + logger.debug('persistent ID attribute: %r', ppid) + identifier = None + for key in attributes: + if not isinstance(key, tuple): + continue + if key[0] == ppid and attributes[key]: + identifier = attributes[key][0] + break + assert identifier, 'persistent ID attribute is missing' + logger.debug('persistent ID attribute value: %r', identifier) + login.nameIdentifier.content = identifier + else: + logger.debug('No persistent ID attribute configured') + login.nameIdentifier.format = \ + lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT + login.nameIdentifier.nameQualifier = entity_id + + user = authenticate(name_id=nid, provider_id=entity_id) + if not user and policy.handle_persistent == \ + 'AUTHSAML2_UNAUTH_PERSISTENT_CREATE_USER_PSEUDONYMOUS': + # Auto-create an user then do the authentication again + user = authenticate(name_id=nid, provider_id=entity_id, + create=True) + if user: + return finish_sso(request, login, user, attributes, next_url) + elif policy.handle_persistent == \ + 'AUTHSAML2_UNAUTH_PERSISTENT_ACCOUNT_LINKING_BY_AUTH': + # Check if the user consent for federation has been given + if policy.force_user_consent \ + and not login.response.consent in \ + ('urn:oasis:names:tc:SAML:2.0:consent:obtained', + 'urn:oasis:names:tc:SAML:2.0:consent:prior', + 'urn:oasis:names:tc:SAML:2.0:consent:current-explicit', + 'urn:oasis:names:tc:SAML:2.0:consent:current-implicit'): + return error_page(request, _('You were \ + not asked your consent for account linking'), + logger=logger) + if request.user.is_authenticated(): + add_federation(request.user, name_id=nid, provider_id=entity_id) + return HttpResponseRedirect(next_url) + utils.save_login_session(request, login) + return render(request, 'authsaml2/account_linking.html') + raise NotImplementedError + +def finish_sso(request, login, user, attributes, next_url): + add_federation(user, login.nameIdentifier, + provider_id=login.remoteProviderId) + auth_login(request, user) + set_session_expiration(request, login) + request.session[CURRENT_IDP] = login.remoteProviderId + if request.session.test_cookie_worked(): + request.session.delete_test_cookie() + signals.auth_login.send(sender=None, request=request, + attributes=attributes) + utils.save_login_session(request, login) + return HttpResponseRedirect(next_url) + +def redirect_to_account_linking(request, login, attributes, + next_url): + '''Save current state and redirect to account linking view''' + logger.debug('redirecting to account linking') + response_id = login.response.id + assert response_id, 'missing response id' + state_key = 'state-%s' % response_id + cache.set(state_key, (login.dump(), attributes, next_url)) + return redirect('a2-auth-saml2-account-linking', + kwargs={'response_id': response_id}) + + +@login_required +def account_linking(request, response_id): + '''Called after assertionConsumer for account linking''' + assert response_id, 'missing response_id' + state_key = 'state-%s' % response_id + state = cache.get(state_key) + assert state, 'missing state' + login_dump, attributes, next_url = state + + login = build_login_from_dump(request, login_dump) + + return finish_sso(request, login, request.user, attributes, + next_url) + + +''' + Single Logout (SLO) + + Initiated by SP or by IdP with SOAP or with Redirect +''' + + +def ko_icon(): + return HttpResponseRedirect('%s/authentic2/images/ko.png' \ + % settings.STATIC_URL) + + +def ok_icon(): + return HttpResponseRedirect('%s/authentic2/images/ok.png' \ + % settings.STATIC_URL) + + +def sp_slo(request): + ''' + To make another module call the SLO function. + Does not deal with the local django session. + ''' + assert 'session_key' in request.REQUEST, 'missing session key' + assert 'entity_id' in request.REQUEST, 'missing entity_id' + session_key = request.REQUEST['session_key'] + entity_id = request.REQUEST.get('entity_id') + next_url = request.REQUEST.get(REDIRECT_FIELD_NAME) \ + or settings.LOGIN_URL + logout = build_logout(request) + load_provider(request, entity_id, server=logout.server, sp_or_idp='idp') + utils.load_logout_session(logout, session_key, entity_id) + try: + logout.initRequest(entity_id) + except lasso.Error, e: + lasso_error(request, 'logout.initRequest', e) + request_id = logout.request.id + try: + logout.buildRequestMsg() + except lasso.Error, e: + lasso_error(request, 'logout.buildRequestMsg', e) + # SOAP case + if logout.msgBody: + soap_response = send_soap_request(request, logout) + return process_logout_response(request, logout, soap_response, session_key, next_url) + else: + request['state-%s' % request_id] = logout.dump(), session_key, entity_id, next_url + return HttpResponseRedirect(logout.msgUrl) + + +def process_logout_response(request, logout, soap_response, + session_key, next_url): + try: + logout.processResponseMsg(soap_response) + except lasso.Error, error: + lasso_error(request, 'logout.processResponseMsg', error) + else: + utils.kill_logout_session(session_key, entity_id) + return HttpResponseRedirect(next_url) + + +def slo_sp_response(request): + ''' + IdP response to a SLO SP initiated by redirect + ''' + logout = build_logout(request) + utils.load_logout_session(logout, request=request) + query = get_saml2_query_request(request) + provider_loaded = None + provider_id = None + while True: + try: + logout.processResponseMsg(query) + break + except (lasso.ServerProviderNotFoundError, + lasso.ProfileUnknownProviderError): + provider_id = logout.remoteProviderId + provider_loaded = load_provider(request, provider_id, + server=logout.server, sp_or_idp='idp') + assert provider_loaded, 'unable to load provider' + continue + except lasso.Error, e: + lasso_error(request, 'logout.processResponseMsg', e) + try: + request_id = logout.response.inResponseTo + except AttributeError: + raise AssertionError('missing inResponseTo') + state = request.session.get('state-%s' % request_id) + assert state, 'missing state' + dump, session_key, entity_id, next_url = state + assert logout.remoteProviderId == entity_id, 'entityID mismatch' + utils.kill_logout_session(session_key, entity_id) + return HttpResponseRedirect(next_url) + + +def slo_common(request, message): + '''Common processing between SOAP and asynchronous SLO request handlers''' + logout = build_logout(request) + + provider_loaded = None + while True: + try: + logout.processRequestMsg(message) + break + except (lasso.ServerProviderNotFoundError, + lasso.ProfileUnknownProviderError): + provider_id = logout.remoteProviderId + provider_loaded = load_provider(request, provider_id, + server=logout.server, sp_or_idp='idp') + + if not provider_loaded: + logger.warn('provider %r unknown', provider_id) + return return_logout_error(request, logout, + AUTHENTIC_STATUS_CODE_UNKNOWN_PROVIDER), None, None + else: + continue + except lasso.Error, e: + logger.error('logout.processRequestMsg %s', e) + return return_logout_error(request, logout, + AUTHENTIC_STATUS_CODE_INTERNAL_SERVER_ERROR), None, None + + policy = get_idp_options_policy(provider_loaded) + if not policy or not policy.accept_slo: + if not policy: + logger.error('no policy found for %r', + logout.remoteProviderId) + elif not policy.accept_slo: + logger.warn('received slo from %r not authorized', + logout.remoteProviderId) + return return_logout_error(request, logout, + AUTHENTIC_STATUS_CODE_UNAUTHORIZED), None, None + + # Look for a session index + if hasattr(logout.request, 'sessionIndexes'): + session_indexes = logout.request.sessionIndexes + else: + session_indexes = [] + if logout.request.sessionIndex: + session_indexes.append(logout.request.sessionIndex) + + name_id = logout.request.nameId + assert name_id, 'missing NameID' + if not utils.has_sessions(name_id, session_indexes): + logger.warning('no sessions found for name id %r and ' + 'sessions indexes %r', nameid2kwargs(name_id), + session_indexes) + return return_logout_error(request, logout, + lasso.SAML2_STATUS_CODE_REQUESTER), None, None + try: + logout.validateRequest() + except lasso.Error, e: + lasso_error('logout.validateRequest', e) + sessions = utils.get_session(name_id, session_indexes) + return None, logout, sessions + +@csrf_exempt +def slo_soap(request): + '''SOAP SLO''' + message = get_soap_message(request) + request_type = lasso.getRequestTypeFromSoapMsg(message) + assert request_type == lasso.REQUEST_TYPE_LOGOUT, 'not a logout request' + + error, logout, sessions = slo_common(request, message) + if error: # early return + return error + if not utils.can_do_synchronous_logout(sessions): + logger.warning('cannot do SOAP logout because there are IdP ' + 'sessions') + return return_logout_error(request, logout, + lasso.SAML2_STATUS_CODE_UNSUPPORTED_PROFILE) + for session_key in utils.get_session_keys(sessions): + flush_django_session(session_key) + sessions.delete() + return slo_return_response(request, logout) + +def finish_slo(request): + '''Return response to the IdP issuer of the logout request''' + request_id = request.REQUEST.get('id') + assert request_id, 'missing id argument' + logout_dump, session_key, entity_id = get_and_delete_key_values(request_id) + server = create_server(request) + logout = lasso.Logout.newFromDump(server, logout_dump) + load_provider(request, entity_id, server=logout.server, sp_or_idp='idp') + return slo_return_response(request, logout) + +def singleLogout(request): + '''POST or Redirect SLO by IdP''' + message = get_saml2_query_request(request) + error, sessions, logout = slo_common(request, message) + if error: # early return + return error + sessions.delete() + key = logout.request.id + dump = logout.dump() + session_key = request.session.session_key + entity_id = logout.remoteProviderId + save_key_values(key, dump, session_key, entity_id) + query = urlencode({'id': key}) + next_url = '{0}?{1}'.format(reverse('a2-auth-saml2-finish-slo'), query) + return idp_views.redirect_to_logout(request, next_page=next_url) + +def slo_return_response(request, logout): + '''Return response to requesting IdP''' + try: + logout.buildResponseMsg() + except lasso.Error, error: + lasso_error(request, 'logout.buildResponseMsg', error) + return return_saml2_response(request, logout) + +############################################# +# Helper functions +############################################# + +def get_provider_id_and_options(provider_id): + if not provider_id: + provider_id = reverse(metadata) + options = metadata_options + if getattr(settings, 'AUTHSAML2_METADATA_OPTIONS', None): + options.update(settings.AUTHSAML2_METADATA_OPTIONS) + return provider_id, options + +def get_metadata(request, provider_id=None): + provider_id, options = get_provider_id_and_options(provider_id) + return get_saml2_metadata(request, provider_id, sp_map=metadata_map, + options=options) + +def create_server(request, provider_id=None): + provider_id, options = get_provider_id_and_options(provider_id) + return create_saml2_server(request, provider_id, sp_map=metadata_map, + options=options) + +def http_response_bad_request(message): + logger.error(message) + return HttpResponseBadRequest(_(message)) + +def http_response_forbidden_request(message): + logger.error(message) + return HttpResponseForbidden(_(message)) + +def build_service_provider(request): + server = create_server(request, reverse(metadata)) + assert server is not None, 'unable to build a LassoServer object' + return server + +def build_login(server): + login = lasso.Login(server) + assert login is not None, 'unable to build a LassoLogin object' + return login + +def build_login_from_dump(request, dump): + server = build_service_provider(request) + login = lasso.Login.newFromDump(server, dump) + assert login is not None, 'unable to build a LassoLogin object' + return login + +def build_logout(request): + server = build_service_provider(request) + logout = lasso.Logout(server) + assert logout is not None, 'unable to build a LassoLogout objects' + return logout + +def setAuthnrequestOptions(provider, login, force_authn, is_passive): + if not provider or not login: + return None + + p = get_idp_options_policy(provider) + if not p: + return None + + if p.no_nameid_policy: + login.request.nameIDPolicy = None + else: + login.request.nameIDPolicy.format = \ + NAME_ID_FORMATS[p.requested_name_id_format]['samlv2'] + login.request.nameIDPolicy.allowCreate = p.allow_create + login.request.nameIDPolicy.spNameQualifier = None + + if p.enable_binding_for_sso_response: + login.request.protocolBinding = p.binding_for_sso_response + + if force_authn is None: + force_authn = p.want_force_authn_request + login.request.forceAuthn = force_authn + + if is_passive is None: + is_passive = p.want_is_passive_authn_request + login.request.isPassive = is_passive + + return p + +def lasso_error(request, call_name, error): + logger.error('in %s: %s', call_name, str(error)) + raise error + +def set_session_expiration(request, login): + session_not_on_or_after = get_session_not_on_or_after(login.assertion) + if session_not_on_or_after: + request.session.set_expiry(session_not_on_or_after) + logger.debug('session expiration %r', session_not_on_or_after) diff --git a/authentic2_auth_saml2/views_disco.py b/authentic2_auth_saml2/views_disco.py new file mode 100644 index 0000000..7ba7c53 --- /dev/null +++ b/authentic2_auth_saml2/views_disco.py @@ -0,0 +1,73 @@ +import logging +import urlparse +import urllib + +from django.core.urlresolvers import reverse +from django.conf import settings +from django.http import Http404, HttpResponseRedirect +from django.utils.translation import ugettext as _ + +from .saml2_endpoints import metadata +from .utils import register_next_target, error_page, get_registered_url + +logger = logging.getLogger(__name__) + + +def get_return_id_param(): + return getattr(settings, 'DISCO_RETURN_ID_PARAM', 'entityID') + +############################################################## +# +# Discovery service Requester +# See Identity Provider Discovery Service Protocol and Profile +# OASIS Committee Specification 01 +# 27 March 2008 +# +############################################################## +def build_discovery_url(request, target): + '''Build the URL for redirection to the disctovery service''' + scheme, netloc, path, params, query, fragment = urlparse.urlparse(target) + # Mix query strings + d = urlparse.parse_qs(query) + d.update({ + 'entityID': request.build_absolute_uri(reverse(metadata)), + 'return': request.build_absolute_uri(reverse(disco_response)), + 'returnIDParam': get_return_id_param(), + }) + query = urllib.urlencode(d) + return urlparse.urlunparse((scheme, netloc, path, params, query, fragment)) + + +def redirect_to_disco(request): + '''Send a discovery request to the default disco service''' + if not hasattr(settings, 'DISCO_SERVICE_NAME'): + raise Http404 + register_next_target(request) + try: + target = settings.DISCO_SERVICE_NAME + except: + logger.error('missing parameter in settings') + return None + url = build_discovery_url(request, target) + if not url: + msg = _('redirect_to_disco: unable to build disco request') + return error_page(request, msg, logger=logger) + return HttpResponseRedirect(url) + + +def disco_response(request): + '''Handle the discovery response''' + if not hasattr(settings, 'DISCO_SERVICE_NAME'): + raise Http404 + if not request.method == "GET": + message = _('HTTP request not supported') + return error_page(request, message, logger=logger) + provider = request.GET.get(get_return_id_param(), '') + if provider: + request.session['prefered_idp'] = provider + logger.debug('discovered %s', provider) + else: + logger.debug('no provider discovered') + return HttpResponseRedirect(get_registered_url(request)) + + diff --git a/authentic2_auth_saml2/views_profile.py b/authentic2_auth_saml2/views_profile.py new file mode 100644 index 0000000..5906eee --- /dev/null +++ b/authentic2_auth_saml2/views_profile.py @@ -0,0 +1,52 @@ +import logging + +from django.template.loader import render_to_string +from django.template import RequestContext +from django.views.decorators.csrf import csrf_exempt +from django.utils.translation import ugettext as _ +from django.shortcuts import redirect +from django.contrib.auth import REDIRECT_FIELD_NAME +from django.contrib.auth.decorators import login_required +from django.contrib import messages + +from authentic2.saml.models import LibertyProvider, LibertyFederation + +from . import forms + +logger = logging.getLogger(__name__) + +def profile(request, template_name='authsaml2/profile.html'): + linked_providers = LibertyProvider.objects.with_federation(request.user) + unlinked_providers = LibertyProvider.objects.idp_enabled() \ + .without_federation(request.user) + choices = list(forms.provider_list_to_choices(unlinked_providers)) + + form = forms.AuthSAML2Form() + if not choices: + form = None + else: + form.fields['entity_id'].choices = choices + context = {'submit_name': 'submit-auhthsaml2', + REDIRECT_FIELD_NAME: '/profile', + 'form': form, + 'next': next, + 'linked_providers': linked_providers, + 'base': '/authsaml2', + } + return render_to_string(template_name, context, RequestContext(request)) + + +@login_required +@csrf_exempt +def delete_federation(request, provider_id): + '''Delete federations with the passed provider''' + if request.method == "POST": + qs = LibertyFederation.objects.filter(user=request.user, + idp__liberty_provider__id=provider_id) + federations = list(qs) + qs.update(user=None) + for federation in federations: + logger.info('federation %s deleted', federation.id) + msg = _('Successful federation deletion.') + messages.add_message(request, messages.INFO, msg) + return redirect('account_management') diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..7253467 --- /dev/null +++ b/setup.py @@ -0,0 +1,48 @@ +#!/usr/bin/python +from setuptools import setup, find_packages +import os + +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='authentic2-auth-saml2', + version=get_version(), + license='AGPLv3', + description='Authentic2 Auth SAML2', + author="Entr'ouvert", + author_email="info@entrouvert.com", + packages=find_packages(os.path.dirname(__file__) or '.'), + install_requires=[], + entry_points={ + 'authentic2.plugin': [ + 'authentic-auth-saml2 = authentic2_auth_saml2:Plugin', + ], + }, +)