--- /dev/null
+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>
--- /dev/null
+recursive-include cas_provider/templates *.html
+include AUTHORS.txt
+include README.rst
+include LICENSE
\ No newline at end of file
django-cas-provider
===================
----------------------------------
-Chris Williams <chris@nitron.org>
----------------------------------
-
OVERVIEW
=========
``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
======
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
=====================
* 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
-----------
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>
+
_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():
--- /dev/null
+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)
{% 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 %}
{% 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 }}">
from django.core.urlresolvers import reverse
from django.test import TestCase
from urlparse import urlparse
+from django.conf import settings
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)
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')):
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)
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):
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()]
+ }
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
# 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)
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):
}, 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')
-
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',