From 2a1924c2ad3c448821641a22d98631dae1c025f2 Mon Sep 17 00:00:00 2001 From: Alex Kamedov Date: Wed, 27 Apr 2011 20:48:09 +0600 Subject: [PATCH] Now you can use custom formatters to custom user attributes --- README.rst | 73 ++++++++++++++++++++++++++-- cas_provider/__init__.py | 2 + cas_provider/attribute_formatters.py | 41 ++++++++++++++++ cas_provider/tests.py | 72 +++++++++++++++++++++++++-- cas_provider/views.py | 33 +++++++++---- 5 files changed, 203 insertions(+), 18 deletions(-) create mode 100644 cas_provider/attribute_formatters.py diff --git a/README.rst b/README.rst index 1efabfe..a0be271 100644 --- a/README.rst +++ b/README.rst @@ -26,7 +26,9 @@ To install, run the following command from this directory: ``python setup.py install`` Or, put cas_provider somewhere on your Python path. - + +If you want use CAS v.2 protocol or above, you must install `lxml` package to correct work. + USAGE ====== @@ -39,7 +41,16 @@ USAGE SETTINGS ========= -CAS_TICKET_EXPIRATION - minutes to tickets expiration (default is 5 minutes) +CAS_TICKET_EXPIRATION - minutes to tickets expiration. Default is 5 minutes. + +CAS_CUSTOM_ATTRIBUTES_CALLBACK - name of callback to provide dictionary with +extended user attributes (may be used in CAS v.2 or above). Default is None. + +CAS_CUSTOM_ATTRIBUTES_FORMAT - name of custom attribute formatter callback will be +used to format custom user attributes. This package provide module `attribute_formatters` +with formatters for common used formats. Available formats styles are `RubyCAS`, `Jasig` +and `Name-Value. Default is Jasig style. See module source code for more details. + PROTOCOL DOCUMENTATION ===================== @@ -67,8 +78,8 @@ Optional arguments: * warn_template_name - warning page template name to allow login user to service if he already authenticated in SSO (default is 'cas/warn.html') -If request.GET has 'warn' argument - it shows warning message if user has already -authenticated in SSO instead of generate Service Ticket and redirect. +If request.GET has 'warn' argument and user has already authenticated in SSO it shows +warning message instead of generate Service Ticket and redirect. logout ----------- @@ -100,3 +111,57 @@ Work with proxy is not supported yet. It has not arguments. +CUSTOM USER ATTRIBUTES FORMAT +=========================== + +Custom attribute format style may be changed in project settings with +CAS_CUSTOM_ATTRIBUTES_FORMAT constant. You can provide your own formatter callback +or specify existing in this package in `attribute_formatters` module. + +Attribute formatter callback takes two arguments: + +* `auth_success` - `cas:authenticationSuccess` node. It is `lxml.etree.SubElement`instance; +* `attrs` - dictionary with user attributes received from callback specified in + CAS_CUSTOM_ATTRIBUTES_CALLBACK in project settings. + +Example of generated XML below: + + + + jsmith + + + + PGTIOU-84678-8a9d2sfa23casd + + + + +* Name-Value style (provided in `cas_provider.attribute_formatters.name_value`): + + + + + + + + +* Jasig Style attributes (provided in `cas_provider.attribute_formatters.jasig`): + + + Jasig + Smith + John + CN=Staff,OU=Groups,DC=example,DC=edu + CN=Spanish Department,OU=Departments,OU=Groups,DC=example,DC=edu + + + +* RubyCAS style (provided in `cas_provider.attribute_formatters.ruby_cas`): + + RubyCAS + Smith + John + CN=Staff,OU=Groups,DC=example,DC=edu + CN=Spanish Department,OU=Departments,OU=Groups,DC=example,DC=edu + diff --git a/cas_provider/__init__.py b/cas_provider/__init__.py index 2ce1450..449c7b6 100644 --- a/cas_provider/__init__.py +++ b/cas_provider/__init__.py @@ -4,6 +4,8 @@ __all__ = [] _DEFAULTS = { 'CAS_TICKET_EXPIRATION': 5, # In minutes + 'CAS_CUSTOM_ATTRIBUTES_CALLBACK': None, + 'CAS_CUSTOM_ATTRIBUTES_FORMATER': 'cas_provider.attribute_formatters.jasig', } for key, value in _DEFAULTS.iteritems(): diff --git a/cas_provider/attribute_formatters.py b/cas_provider/attribute_formatters.py new file mode 100644 index 0000000..3f3ab3c --- /dev/null +++ b/cas_provider/attribute_formatters.py @@ -0,0 +1,41 @@ +from lxml import etree + +CAS_URI = 'http://www.yale.edu/tp/cas' +NSMAP = {'cas': CAS_URI} +CAS = '{%s}' % CAS_URI + + +def jasig(auth_success, attrs): + attributes = etree.SubElement(auth_success, CAS + 'attributes') + style = etree.SubElement(attributes, CAS + 'attraStyle') + style.text = u'Jasig' + for name, value in attrs.items(): + if isinstance(value, list): + for e in value: + element = etree.SubElement(attributes, CAS + name) + element.text = e + else: + element = etree.SubElement(attributes, CAS + name) + element.text = value + + +def ruby_cas(auth_success, attrs): + style = etree.SubElement(auth_success, CAS + 'attraStyle') + style.text = u'RubyCAS' + for name, value in attrs.items(): + if isinstance(value, list): + for e in value: + element = etree.SubElement(auth_success, CAS + name) + element.text = e + else: + element = etree.SubElement(auth_success, CAS + name) + element.text = value + +def name_value(auth_success, attrs): + etree.SubElement(auth_success, CAS + 'attribute', name=u'attraStyle', value=u'Name-Value') + for name, value in attrs.items(): + if isinstance(value, list): + for e in value: + etree.SubElement(auth_success, CAS + 'attribute', name=name, value=e) + else: + etree.SubElement(auth_success, CAS + 'attribute', name=name, value=value) diff --git a/cas_provider/tests.py b/cas_provider/tests.py index c876148..4b486de 100644 --- a/cas_provider/tests.py +++ b/cas_provider/tests.py @@ -5,6 +5,7 @@ from django.contrib.auth.models import User from django.core.urlresolvers import reverse from django.test import TestCase from urlparse import urlparse +from django.conf import settings class ViewsTest(TestCase): @@ -35,12 +36,16 @@ class ViewsTest(TestCase): self.assertTemplateUsed(response, 'cas/warn.html') + def _cas_logout(self): + response = self.client.get(reverse('cas_logout'), follow=False) + self.assertEqual(response.status_code, 200) + + def test_logout(self): response = self._login_user('root', '123') self._validate_cas1(response, True) - response = self.client.get(reverse('cas_logout'), follow=False) - self.assertEqual(response.status_code, 200) + self._cas_logout() response = self.client.get(reverse('cas_login'), follow=False) self.assertEqual(response.status_code, 200) @@ -58,7 +63,58 @@ class ViewsTest(TestCase): def test_cas2_success_validate(self): response = self._login_user('root', '123') - self._validate_cas2(response, True) + response = self._validate_cas2(response, True) + user = User.objects.get(username=self.username) + self.assertEqual(response.content, _cas2_sucess_response(user).content) + + def test_cas2_custom_attrs(self): + settings.CAS_CUSTOM_ATTRIBUTES_CALLBACK = cas_mapping + response = self._login_user('editor', '123') + + response = self._validate_cas2(response, True) + self.assertEqual(response.content, '''''' + '''''' + '''editor''' + '''''' + '''Jasig''' + '''editor''' + '''True''' + '''True''' + '''editor@exapmle.com''' + '''''' + '''''' + '''''') + + self._cas_logout() + response = self._login_user('editor', '123') + settings.CAS_CUSTOM_ATTRIBUTES_FORMATER = 'cas_provider.attribute_formatters.ruby_cas' + response = self._validate_cas2(response, True) + self.assertEqual(response.content, '''''' + '''''' + '''editor''' + '''RubyCAS''' + '''editor''' + '''True''' + '''True''' + '''editor@exapmle.com''' + '''''' + '''''') + + self._cas_logout() + response = self._login_user('editor', '123') + settings.CAS_CUSTOM_ATTRIBUTES_FORMATER = 'cas_provider.attribute_formatters.name_value' + response = self._validate_cas2(response, True) + self.assertEqual(response.content, '''''' + '''''' + '''editor''' + '''''' + '''''' + '''''' + '''''' + '''''' + '''''' + '''''') + def test_cas2_fail_validate(self): for user, pwd in (('root', '321'), ('notroot', '123'), ('nonactive', '123')): @@ -120,7 +176,6 @@ class ViewsTest(TestCase): response = self.client.get(reverse('cas_service_validate'), {'ticket': ticket, 'service': self.service}, follow=False) self.assertEqual(response.status_code, 200) - self.assertEqual(response.content, _cas2_sucess_response(self.username).content) else: self.assertEqual(response.status_code, 200) self.assertEqual(len(response.context['form'].errors), 1) @@ -128,6 +183,7 @@ class ViewsTest(TestCase): response = self.client.get(reverse('cas_service_validate'), {'ticket': 'ST-12312312312312312312312', 'service': self.service}, follow=False) self.assertEqual(response.status_code, 200) self.assertEqual(response.content, _cas2_error_response(INVALID_TICKET).content) + return response class ModelsTestCase(TestCase): @@ -141,3 +197,11 @@ class ModelsTestCase(TestCase): ticket = ServiceTicket.objects.create(service='http://example.com', user=self.user) self.assertEqual(ticket.get_redirect_url(), '%(service)s?ticket=%(ticket)s' % ticket.__dict__) + +def cas_mapping(user): + return { + 'is_staff': unicode(user.is_staff), + 'is_active': unicode(user.is_active), + 'email': user.email, + 'group': [g.name for g in user.groups.all()] + } diff --git a/cas_provider/views.py b/cas_provider/views.py index 9472686..4a7e9b8 100644 --- a/cas_provider/views.py +++ b/cas_provider/views.py @@ -1,6 +1,6 @@ from django.conf import settings -from django.contrib.auth import login as auth_login, \ - logout as auth_logout +from django.contrib.auth import login as auth_login, logout as auth_logout +from django.core.urlresolvers import get_callable from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import render_to_response from django.template import RequestContext @@ -102,9 +102,9 @@ def service_validate(request): ticket.delete() return _cas2_error_response(INVALID_SERVICE) - username = ticket.user.username + user = ticket.user ticket.delete() - return _cas2_sucess_response(username) + return _cas2_sucess_response(user) def _cas2_error_response(code): @@ -118,9 +118,22 @@ def _cas2_error_response(code): }, mimetype='text/xml') -def _cas2_sucess_response(username): - return HttpResponse(u''' - - %(username)s - - ''' % {'username': username}, mimetype='text/xml') +def _cas2_sucess_response(user): + return HttpResponse(auth_success_response(user), mimetype='text/xml') + + +def auth_success_response(user): + from attribute_formatters import CAS, NSMAP, etree + + response = etree.Element(CAS + 'serviceResponse', nsmap=NSMAP) + auth_success = etree.SubElement(response, CAS + 'authenticationSuccess') + username = etree.SubElement(auth_success, CAS + 'user') + username.text = user.username + + if settings.CAS_CUSTOM_ATTRIBUTES_CALLBACK: + callback = get_callable(settings.CAS_CUSTOM_ATTRIBUTES_CALLBACK) + attrs = callback(user) + if len(attrs) > 0: + formater = get_callable(settings.CAS_CUSTOM_ATTRIBUTES_FORMATER) + formater(auth_success, attrs) + return unicode(etree.tostring(response, encoding='utf-8'), 'utf-8') -- 2.20.1