Merge branch 'develop'
authorAlex Kamedov <alex@kamedov.ru>
Wed, 27 Apr 2011 14:49:43 +0000 (20:49 +0600)
committerAlex Kamedov <alex@kamedov.ru>
Wed, 27 Apr 2011 14:49:43 +0000 (20:49 +0600)
AUTHORS.txt [new file with mode: 0644]
MANIFEST.in [new file with mode: 0644]
README.rst
cas_provider/__init__.py
cas_provider/attribute_formatters.py [new file with mode: 0644]
cas_provider/templates/cas/login.html
cas_provider/templates/cas/warn.html
cas_provider/tests.py
cas_provider/views.py
setup.py

diff --git a/AUTHORS.txt b/AUTHORS.txt
new file mode 100644 (file)
index 0000000..af9d445
--- /dev/null
@@ -0,0 +1,5 @@
+Chris Williams <chris@nitron.org>
+Alex Kamedov <alex@kamedov.ru>
+Ɓukasz Rekucki <lrekucki@gmail.com>
+Marek Stepniowski <marek@stepniowski.com>
+Fred Wenzel <fwenzel@mozilla.com>
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644 (file)
index 0000000..775b4b5
--- /dev/null
@@ -0,0 +1,4 @@
+recursive-include cas_provider/templates *.html
+include AUTHORS.txt
+include README.rst
+include LICENSE
\ No newline at end of file
index bc2874d..0ef1742 100644 (file)
@@ -2,10 +2,6 @@
 django-cas-provider
 ===================
 
----------------------------------
-Chris Williams <chris@nitron.org>
----------------------------------
-
 OVERVIEW
 =========
 
@@ -26,7 +22,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,8 +37,16 @@ USAGE
 SETTINGS
 =========
 
-CAS_TICKET_EXPIRATION - minutes to tickets expiration (default is 5 minutes)
-CAS_CHECK_SERVICE - check if ticket service is equal with service GET argument
+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
 =====================
@@ -68,8 +74,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
 -----------
@@ -101,3 +107,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:
+     <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 91b3b2c..449c7b6 100644 (file)
@@ -4,7 +4,8 @@ __all__ = []
 
 _DEFAULTS = {
     'CAS_TICKET_EXPIRATION': 5, # In minutes
-    'CAS_CHECK_SERVICE': False,
+    '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 (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 d61974a..d9e933e 100644 (file)
@@ -5,9 +5,10 @@ Login
 {% endblock %}
 
 {% block content %}
-  <form action='.' method='post'>
+  <form action='{% url cas_login %}' method='post'>
     <fieldset>
       <legend>Log in to your account</legend>
+         {% csrf_token %}
       {% if errors %}
         <ul>
             {% for error in errors %}
index 58f4ed8..ca9e561 100644 (file)
@@ -5,7 +5,7 @@ Warning
 {% endblock %}
 
 {% block content %}
-  <form action='.' method='get'>
+  <form action='{% url cas_login %}' method='get'>
     <fieldset>
       <legend>Confirm to log in to {{ service }}</legend>
          <input type="hidden" name="service" value="{{ service }}">
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.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, '''<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')):
@@ -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()]
+    }
index 32ed6e9..4a7e9b8 100644 (file)
@@ -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
@@ -70,6 +70,7 @@ def validate(request):
         # TODO: check user SSO session
         try:
             ticket = ServiceTicket.objects.get(ticket=ticket_string)
+            assert ticket.service == service
             username = ticket.user.username
             ticket.delete()
             return HttpResponse("yes\n%s\n" % username)
@@ -97,13 +98,13 @@ def service_validate(request):
     except ServiceTicket.DoesNotExist:
         return _cas2_error_response(INVALID_TICKET)
 
-    if settings.CAS_CHECK_SERVICE and ticket.service != service:
+    if ticket.service != service:
         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):
@@ -117,9 +118,22 @@ def _cas2_error_response(code):
     }, 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')
index 8f06deb..2d65aaf 100644 (file)
--- a/setup.py
+++ b/setup.py
@@ -1,9 +1,9 @@
-
 from setuptools import setup, find_packages
+
+
 setup(
     name='django-cas-provider',
-    version='0.1dev',
+    version='0.2.2',
     description='A "provider" for the Central Authentication Service (http://jasig.org/cas)',
     author='Chris Williams',
     author_email='chris@nitron.org',