first commit
This commit is contained in:
commit
a1646489e2
|
@ -0,0 +1,2 @@
|
|||
{{ project_name }} is entirely under the copyright of Entr'ouvert and distributed
|
||||
under the license AGPLv3 or later.
|
|
@ -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.
|
|
@ -0,0 +1 @@
|
|||
__version__ = '1.0.0'
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
|
@ -0,0 +1,3 @@
|
|||
from django.db import models
|
||||
|
||||
# Create your models here.
|
|
@ -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 %}
|
|
@ -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>
|
|
@ -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 %}
|
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
|
@ -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'),
|
||||
)
|
|
@ -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)
|
|
@ -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')
|
|
@ -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,
|
||||
},
|
||||
)
|
Loading…
Reference in New Issue