Merge branch 'vshulyak' into develop
authorAlex Kamedov <alex@kamedov.ru>
Wed, 27 Apr 2011 15:40:37 +0000 (21:40 +0600)
committerAlex Kamedov <alex@kamedov.ru>
Wed, 27 Apr 2011 15:40:37 +0000 (21:40 +0600)
1  2 
README.rst
cas_provider/admin.py
cas_provider/views.py
setup.py

diff --combined README.rst
@@@ -5,26 -5,18 +5,21 @@@ django-cas-provide
  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>
 +
diff --combined cas_provider/admin.py
@@@ -1,13 -1,11 +1,14 @@@
  from django.contrib import admin
 +from models import *
  
 -from cas_provider.models import ServiceTicket, LoginTicket
  class ServiceTicketAdmin(admin.ModelAdmin):
 -    pass
 -admin.site.register(ServiceTicket, ServiceTicketAdmin)
 +    list_display = ('user', 'service', 'created')
 +    list_filter = ('created',)
  
  class LoginTicketAdmin(admin.ModelAdmin):
 -    pass
 -admin.site.register(LoginTicket, LoginTicketAdmin)
 +    list_display = ('ticket', 'created')
 +    list_filter = ('created',)
 +
 +admin.site.register(ServiceTicket, ServiceTicketAdmin)
 +admin.site.register(LoginTicket, LoginTicketAdmin)
diff --combined cas_provider/views.py
@@@ -1,76 -1,62 +1,76 @@@
 -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')
diff --combined setup.py
+++ b/setup.py
@@@ -1,14 -1,14 +1,15 @@@
  from setuptools import setup, find_packages
 - 
 +
 +
  setup(
      name='django-cas-provider',
 -    version='0.2.1',
 +    version='0.2.2',
      description='A "provider" for the Central Authentication Service (http://jasig.org/cas)',
      author='Chris Williams',
      author_email='chris@nitron.org',
      url='http://nitron.org/',
      packages=find_packages(),
+     include_package_data=True,
      zip_safe=False,
      install_requires=['setuptools'],
  )