Now you can use custom formatters to custom user attributes
authorAlex Kamedov <alex@kamedov.ru>
Wed, 27 Apr 2011 14:48:09 +0000 (20:48 +0600)
committerAlex Kamedov <alex@kamedov.ru>
Wed, 27 Apr 2011 14:48:09 +0000 (20:48 +0600)
README.rst
cas_provider/__init__.py
cas_provider/attribute_formatters.py [new file with mode: 0644]
cas_provider/tests.py
cas_provider/views.py

index 1efabfe..a0be271 100644 (file)
@@ -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.
        ``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
 ======
 
 USAGE
 ======
 
@@ -39,7 +41,16 @@ USAGE
 SETTINGS
 =========
 
 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
 =====================
 
 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')
 
 * 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
 -----------
 
 logout
 -----------
@@ -100,3 +111,57 @@ Work with proxy is not supported yet.
 It has not arguments.
 
 
 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:
+     <cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
+         <cas:authenticationSuccess>
+             <cas:user>jsmith</cas:user>
+
+             <!-- extended user attributes wiil be here -->
+
+             <cas:proxyGrantingTicket>PGTIOU-84678-8a9d2sfa23casd</cas:proxyGrantingTicket>
+         </cas:authenticationSuccess>
+     </cas:serviceResponse>
+
+
+* Name-Value style (provided in `cas_provider.attribute_formatters.name_value`):
+
+    <cas:attribute name='attraStyle' value='Name-Value' />
+    <cas:attribute name='surname' value='Smith' />
+    <cas:attribute name='givenName' value='John' />
+    <cas:attribute name='memberOf' value='CN=Staff,OU=Groups,DC=example,DC=edu' />
+    <cas:attribute name='memberOf' value='CN=Spanish Department,OU=Departments,OU=Groups,DC=example,DC=edu' />
+
+
+*  Jasig Style attributes (provided in `cas_provider.attribute_formatters.jasig`):
+
+    <cas:attributes>
+        <cas:attraStyle>Jasig</cas:attraStyle>
+        <cas:surname>Smith</cas:surname>
+        <cas:givenName>John</cas:givenName>
+        <cas:memberOf>CN=Staff,OU=Groups,DC=example,DC=edu</cas:memberOf>
+        <cas:memberOf>CN=Spanish Department,OU=Departments,OU=Groups,DC=example,DC=edu</cas:memberOf>
+    </cas:attributes>
+
+
+* RubyCAS style (provided in `cas_provider.attribute_formatters.ruby_cas`):
+
+    <cas:attraStyle>RubyCAS</cas:attraStyle>
+    <cas:surname>Smith</cas:surname>
+    <cas:givenName>John</cas:givenName>
+    <cas:memberOf>CN=Staff,OU=Groups,DC=example,DC=edu</cas:memberOf>
+    <cas:memberOf>CN=Spanish Department,OU=Departments,OU=Groups,DC=example,DC=edu</cas:memberOf>
+
index 2ce1450..449c7b6 100644 (file)
@@ -4,6 +4,8 @@ __all__ = []
 
 _DEFAULTS = {
     'CAS_TICKET_EXPIRATION': 5, # In minutes
 
 _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():
 }
 
 for key, value in _DEFAULTS.iteritems():
diff --git a/cas_provider/attribute_formatters.py b/cas_provider/attribute_formatters.py
new file mode 100644 (file)
index 0000000..3f3ab3c
--- /dev/null
@@ -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)
index c876148..4b486de 100644 (file)
@@ -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.core.urlresolvers import reverse
 from django.test import TestCase
 from urlparse import urlparse
+from django.conf import settings
 
 
 class ViewsTest(TestCase):
 
 
 class ViewsTest(TestCase):
@@ -35,12 +36,16 @@ class ViewsTest(TestCase):
         self.assertTemplateUsed(response, 'cas/warn.html')
 
 
         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)
 
     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)
 
         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')
 
     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, '''<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">'''
+            '''<cas:authenticationSuccess>'''
+                '''<cas:user>editor</cas:user>'''
+                '''<cas:attributes>'''
+                    '''<cas:attraStyle>Jasig</cas:attraStyle>'''
+                    '''<cas:group>editor</cas:group>'''
+                    '''<cas:is_staff>True</cas:is_staff>'''
+                    '''<cas:is_active>True</cas:is_active>'''
+                    '''<cas:email>editor@exapmle.com</cas:email>'''
+                '''</cas:attributes>'''
+            '''</cas:authenticationSuccess>'''
+        '''</cas:serviceResponse>''')
+
+        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, '''<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">'''
+            '''<cas:authenticationSuccess>'''
+                '''<cas:user>editor</cas:user>'''
+                '''<cas:attraStyle>RubyCAS</cas:attraStyle>'''
+                '''<cas:group>editor</cas:group>'''
+                '''<cas:is_staff>True</cas:is_staff>'''
+                '''<cas:is_active>True</cas:is_active>'''
+                '''<cas:email>editor@exapmle.com</cas:email>'''
+            '''</cas:authenticationSuccess>'''
+        '''</cas:serviceResponse>''')
+
+        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, '''<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">'''
+            '''<cas:authenticationSuccess>'''
+                '''<cas:user>editor</cas:user>'''
+                    '''<cas:attribute name="attraStyle" value="Name-Value"/>'''
+                    '''<cas:attribute name="group" value="editor"/>'''
+                    '''<cas:attribute name="is_staff" value="True"/>'''
+                    '''<cas:attribute name="is_active" value="True"/>'''
+                    '''<cas:attribute name="email" value="editor@exapmle.com"/>'''
+            '''</cas:authenticationSuccess>'''
+        '''</cas:serviceResponse>''')
+
 
     def test_cas2_fail_validate(self):
         for user, pwd in (('root', '321'), ('notroot', '123'), ('nonactive', '123')):
 
     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)
 
             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)
         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)
             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):
 
 
 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__)
 
         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()]
+    }
index 9472686..4a7e9b8 100644 (file)
@@ -1,6 +1,6 @@
 from django.conf import settings
 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
 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)
 
         ticket.delete()
         return _cas2_error_response(INVALID_SERVICE)
 
-    username = ticket.user.username
+    user = ticket.user
     ticket.delete()
     ticket.delete()
-    return _cas2_sucess_response(username)
+    return _cas2_sucess_response(user)
 
 
 def _cas2_error_response(code):
 
 
 def _cas2_error_response(code):
@@ -118,9 +118,22 @@ def _cas2_error_response(code):
     }, mimetype='text/xml')
 
 
     }, mimetype='text/xml')
 
 
-def _cas2_sucess_response(username):
-    return HttpResponse(u'''<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">
-        <cas:authenticationSuccess>
-            <cas:user>%(username)s</cas:user>
-        </cas:authenticationSuccess>
-    </cas:serviceResponse>''' % {'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')