OVERVIEW
=========
- django-cas-provider is a provider for the `Central Authentication
- Service <http://jasig.org/cas>`_. It supports CAS version 1.0. It allows
- remote services to authenticate users for the purposes of
- Single Sign-On (SSO). For example, a user logs into a CAS server
- (provided by django-cas-provider) and can then access other services
- (such as email, calendar, etc) without re-entering her password for
- each service. For more details, see the `CAS wiki <http://www.ja-sig.org/wiki/display/CAS/Home>`_
- and `Single Sign-On on Wikipedia <http://en.wikipedia.org/wiki/Single_Sign_On>`_.
+ django-cas-provider is a provider for the `Central Authentication Service <http://jasig.org/cas>`_. It supports CAS version 1.0 and parts of CAS version 2.0 protocol. It allows remote services to authenticate users for the purposes of Single Sign-On (SSO). For example, a user logs into a CAS server
+ (provided by django-cas-provider) and can then access other services (such as email, calendar, etc) without re-entering her password for each service. For more details, see the `CAS wiki <http://www.ja-sig.org/wiki/display/CAS/Home>`_ and `Single Sign-On on Wikipedia <http://en.wikipedia.org/wiki/Single_Sign_On>`_.
INSTALLATION
=============
- To install, run the following command from this directory:
+ To install, run the following command from this directory::
- ``python setup.py install``
- python setup.py install
++ python setup.py install
- Or, put cas_provider somewhere on your Python path.
+ 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
======
#. In *settings.py*, set ``LOGIN_URL`` to ``'/cas/login/'`` and ``LOGOUT_URL`` to ``'/cas/logout/'``
#. In *urls.py*, put the following line: ``(r'^cas/', include('cas_provider.urls')),``
#. Create login/logout templates (or modify the samples)
- Example of generated XML below:
+#. Use 'cleanuptickets' management command to clean up expired tickets
+
+SETTINGS
+=========
+
+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
+=====================
+
+* `CAS Protocol <http://www.jasig.org/cas/protocol>`
+* `CAS 1 Architecture <http://www.jasig.org/cas/cas1-architecture>`
+* `CAS 2 Architecture <http://www.jasig.org/cas/cas2-architecture>`
+* `Proxy Authentication <http://www.jasig.org/cas/proxy-authentication>`
+* `CAS – Central Authentication Service <http://www.jusfortechies.com/cas/overview.html>`
+* `Proxy CAS Walkthrough <https://wiki.jasig.org/display/CAS/Proxy+CAS+Walkthrough>`
+
+PROVIDED VIEWS
+=============
+
+login
+---------
+
+It has not required arguments.
+
+Optional arguments:
+
+* template_name - login form template name (default is 'cas/login.html')
+* success_redirect - redirect after successful login if service GET argument is not provided
+ (default is settings.LOGIN_REDIRECT_URL)
+* 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 and user has already authenticated in SSO it shows
+warning message instead of generate Service Ticket and redirect.
+
+logout
+-----------
+
+This destroys a client's single sign-on CAS session. The ticket-granting cookie is destroyed,
+and subsequent requests to login view will not obtain service tickets until the user again
+presents primary credentials (and thereby establishes a new single sign-on session).
+
+It has not required arguments.
+
+Optional arguments:
+
+* template_name - template name for page with successful logout message (default is 'cas/logout.html')
+
+validate
+-------------
+
+It checks the validity of a service ticket. It is part of the CAS 1.0 protocol and thus does
+not handle proxy authentication.
+
+It has not arguments.
+
+service_validate
+-------------------------
+
+It checks the validity of a service ticket and returns an XML-fragment response via CAS 2.0 protocol.
+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.
+
- * Name-Value style (provided in `cas_provider.attribute_formatters.name_value`):
++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>
+
+
- * Jasig Style attributes (provided in `cas_provider.attribute_formatters.jasig`):
++* 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' />
+
+
- * RubyCAS style (provided in `cas_provider.attribute_formatters.ruby_cas`):
++* 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>
+
-from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect
+from django.conf import settings
+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.contrib.auth.models import User
-from django.contrib.auth import authenticate
-from django.contrib.auth import login as auth_login, logout as auth_logout
+from forms import LoginForm
+from models import ServiceTicket, LoginTicket
+
+
+__all__ = ['login', 'validate', 'logout', 'service_validate']
+
-from cas_provider.forms import LoginForm
-from cas_provider.models import ServiceTicket, LoginTicket, auth_success_response
-from cas_provider.utils import create_service_ticket
+INVALID_TICKET = 'INVALID_TICKET'
+INVALID_SERVICE = 'INVALID_SERVICE'
+INVALID_REQUEST = 'INVALID_REQUEST'
+INTERNAL_ERROR = 'INTERNAL_ERROR'
-__all__ = ['login', 'validate', 'service_validate', 'logout']
+ERROR_MESSAGES = (
+ (INVALID_TICKET, u'The provided ticket is invalid.'),
+ (INVALID_SERVICE, u'Service is invalid'),
+ (INVALID_REQUEST, u'Not all required parameters were sent.'),
+ (INTERNAL_ERROR, u'An internal error occurred during ticket validation'),
+)
-def login(request, template_name='cas/login.html', success_redirect='/accounts/'):
+
+def login(request, template_name='cas/login.html', \
+ success_redirect=settings.LOGIN_REDIRECT_URL,
+ warn_template_name='cas/warn.html'):
service = request.GET.get('service', None)
if request.user.is_authenticated():
if service is not None:
- ticket = create_service_ticket(request.user, service)
- if service.find('?') == -1:
- return HttpResponseRedirect(service + '?ticket=' + ticket.ticket)
- else:
- return HttpResponseRedirect(service + '&ticket=' + ticket.ticket)
+ if request.GET.get('warn', False):
+ return render_to_response(warn_template_name, {
+ 'service': service,
+ 'warn': False
+ }, context_instance=RequestContext(request))
+ ticket = ServiceTicket.objects.create(service=service, user=request.user)
+ return HttpResponseRedirect(ticket.get_redirect_url())
else:
return HttpResponseRedirect(success_redirect)
- errors = []
if request.method == 'POST':
- username = request.POST.get('username', None)
- password = request.POST.get('password', None)
- service = request.POST.get('service', None)
- lt = request.POST.get('lt', None)
-
- try:
- login_ticket = LoginTicket.objects.get(ticket=lt)
- except:
- errors.append('Login ticket expired. Please try again.')
- else:
- login_ticket.delete()
- user = authenticate(username=username, password=password)
- if user is not None:
- if user.is_active:
- auth_login(request, user)
- if service is not None:
- ticket = create_service_ticket(user, service)
- return HttpResponseRedirect(service + '?ticket=' + ticket.ticket)
- else:
- return HttpResponseRedirect(success_redirect)
- else:
- errors.append('This account is disabled.')
- else:
- errors.append('Incorrect username and/or password.')
- form = LoginForm(service)
- return render_to_response(template_name, {'form': form, 'errors': errors}, context_instance=RequestContext(request))
-
+ form = LoginForm(request.POST)
+ if form.is_valid():
+ user = form.get_user()
+ auth_login(request, user)
+ service = form.cleaned_data.get('service')
+ if service is not None:
+ ticket = ServiceTicket.objects.create(service=service, user=user)
+ success_redirect = ticket.get_redirect_url()
+ return HttpResponseRedirect(success_redirect)
+ else:
+ form = LoginForm(initial={
+ 'service': service,
+ 'lt': LoginTicket.objects.create()
+ })
+ return render_to_response(template_name, {
+ 'form': form,
+ 'errors': form.get_errors()
+ }, context_instance=RequestContext(request))
+
+
def validate(request):
+ """Validate ticket via CAS v.1 protocol"""
service = request.GET.get('service', None)
ticket_string = request.GET.get('ticket', None)
if service is not None and ticket_string is not None:
+ #renew = request.GET.get('renew', True)
+ #if not renew:
+ # 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)
pass
return HttpResponse("no\n\n")
- def logout(request, template_name='cas/logout.html'):
+
- auth_logout(request)
++def logout(request, template_name='cas/logout.html', auto_redirect=False):
+ url = request.GET.get('url', None)
++ if request.user.is_authenticated():
++ auth_logout(request)
++ if url and auto_redirect:
++ return HttpResponseRedirect(url)
+ return render_to_response(template_name, {'url': url}, \
+ context_instance=RequestContext(request))
+
+
def service_validate(request):
+ """Validate ticket via CAS v.2 protocol"""
service = request.GET.get('service', None)
ticket_string = request.GET.get('ticket', None)
if service is None or ticket_string is None:
- return HttpResponse('''<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">
- <cas:authenticationFailure code="INVALID_REQUEST">
- Not all required parameters were sent.
- </cas:authenticationFailure>
- </cas:serviceResponse>''', mimetype='text/xml')
-
+ return _cas2_error_response(INVALID_REQUEST)
+
try:
ticket = ServiceTicket.objects.get(ticket=ticket_string)
- ticket.delete()
- return HttpResponse(auth_success_response(ticket.user), mimetype='text/xml')
except ServiceTicket.DoesNotExist:
- return HttpResponse('''<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">
- <cas:authenticationFailure code="INVALID_TICKET">
- The provided ticket is invalid.
+ return _cas2_error_response(INVALID_TICKET)
+
+ if ticket.service != service:
+ ticket.delete()
+ return _cas2_error_response(INVALID_SERVICE)
+
+ user = ticket.user
+ ticket.delete()
+ return _cas2_sucess_response(user)
+
+
+def _cas2_error_response(code):
+ return HttpResponse(u''''<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">
+ <cas:authenticationFailure code="%(code)s">
+ %(message)s
</cas:authenticationFailure>
- </cas:serviceResponse>''', mimetype='text/xml')
+ </cas:serviceResponse>''' % {
+ 'code': code,
+ 'message': dict(ERROR_MESSAGES).get(code)
+ }, mimetype='text/xml')
-def logout(request, template_name='cas/logout.html', auto_redirect=False):
- url = request.GET.get('url', None)
- if request.user.is_authenticated():
- auth_logout(request)
- if url and auto_redirect:
- return HttpResponseRedirect(url)
- return render_to_response(template_name, {'url': url}, context_instance=RequestContext(request))
+
+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')