Merge remote-tracking branch 'cas2/master'
authordeyk <deyk@crossway.org>
Fri, 13 Apr 2012 22:25:48 +0000 (15:25 -0700)
committerdeyk <deyk@crossway.org>
Fri, 13 Apr 2012 22:26:25 +0000 (15:26 -0700)
Conflicts:
.gitignore
cas_provider/forms.py
cas_provider/models.py
cas_provider/urls.py
cas_provider/views.py

Merged from https://github.com/castlabs/django-cas-provider

PT #27996721

31 files changed:
.gitignore
AUTHORS.txt [new file with mode: 0644]
MANIFEST.in [new file with mode: 0644]
README.rst
cas_provider/__init__.py
cas_provider/admin.py
cas_provider/attribute_formatters.py [new file with mode: 0644]
cas_provider/fixtures/cas_users.json [new file with mode: 0644]
cas_provider/forms.py
cas_provider/locale/ru/LC_MESSAGES/django.mo [new file with mode: 0644]
cas_provider/locale/ru/LC_MESSAGES/django.po [new file with mode: 0644]
cas_provider/management/commands/cleanuptickets.py
cas_provider/migrations/0001_initial.py [new file with mode: 0644]
cas_provider/migrations/0002_auto__add_proxygrantingticket__add_proxyticket__add_proxygrantingticke.py [new file with mode: 0644]
cas_provider/migrations/0003_auto__del_field_proxygrantingticket_targetService.py [new file with mode: 0644]
cas_provider/migrations/__init__.py [new file with mode: 0644]
cas_provider/models.py
cas_provider/templates/cas/login.html
cas_provider/templates/cas/warn.html [new file with mode: 0644]
cas_provider/tests.py [new file with mode: 0644]
cas_provider/urls.py
cas_provider/utils.py [deleted file]
cas_provider/views.py
cas_provider_examples/__init__.py [new file with mode: 0644]
cas_provider_examples/simple/__init__.py [new file with mode: 0644]
cas_provider_examples/simple/manage.py [new file with mode: 0644]
cas_provider_examples/simple/settings.py [new file with mode: 0644]
cas_provider_examples/simple/templates/base.html [new file with mode: 0644]
cas_provider_examples/simple/templates/login-success-redirect-target.html [new file with mode: 0644]
cas_provider_examples/simple/urls.py [new file with mode: 0644]
setup.py

index 9063127..4030171 100644 (file)
@@ -1,2 +1,9 @@
+# Python garbage
 *.pyc
-django_cas_provider.egg-info/
+*.egg-info
+
+# Mac OS X garbage
+.DS_Store
+
+# PyDev garbage
+.tmp*
diff --git a/AUTHORS.txt b/AUTHORS.txt
new file mode 100644 (file)
index 0000000..9900ace
--- /dev/null
@@ -0,0 +1,6 @@
+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>
+Sebastian Annies <Sebastian.Annies@castlabs.com>
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644 (file)
index 0000000..a87457c
--- /dev/null
@@ -0,0 +1,5 @@
+recursive-include cas_provider/templates *.html
+recursive-include cas_provider/fixtures *
+include AUTHORS.txt
+include README.rst
+include LICENSE
\ No newline at end of file
index 1d32df1..258d685 100644 (file)
@@ -2,31 +2,46 @@
 django-cas-provider
 ===================
 
----------------------------------
-Chris Williams <chris@nitron.org>
----------------------------------
-
 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
+
+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.
+
+UPDATING FROM PREVIOUS VERSION
+===============================
+
+I introduced south for DB schema migration. The schema from any previous version without south is 0001_initial.
+You will get an error:
+
+    ``Running migrations for cas_provider:``
+
+    ``- Migrating forwards to 0001_initial.``
+
+    ``> cas_provider:0001_initial``
+
+    ``Traceback (most recent call last):``
+
+    ``...``
+
+    ``django.db.utils.DatabaseError: relation "cas_provider_serviceticket" already exists``
+
+to circumvent that problem you will need to fake the initial migration:
+
+ python manage.py migrate cas_provider 0001_initial --fake
 
-       ``python setup.py install``
 
-Or, put cas_provider somewhere on your Python path.
-       
 USAGE
 ======
 
@@ -34,3 +49,136 @@ 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)
+#. 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.
+
+CAS_AUTO_REDIRECT_AFTER_LOGOUT - If False (default behavior, specified in CAS protocol)
+after successful logout notification page will be shown. If it's True, after successful logout will
+be auto redirect back to service without any notification.
+
+
+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. 
+
+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 1a719b4..d5a3520 100644 (file)
@@ -4,6 +4,9 @@ __all__ = []
 
 _DEFAULTS = {
     'CAS_TICKET_EXPIRATION': 5, # In minutes
+    'CAS_CUSTOM_ATTRIBUTES_CALLBACK': None,
+    'CAS_CUSTOM_ATTRIBUTES_FORMATER': 'cas_provider.attribute_formatters.jasig',
+    'CAS_AUTO_REDIRECT_AFTER_LOGOUT': False,
 }
 
 for key, value in _DEFAULTS.iteritems():
@@ -12,4 +15,4 @@ for key, value in _DEFAULTS.iteritems():
     except AttributeError:
         setattr(settings, key, value)
     except ImportError:
-        pass
\ No newline at end of file
+        pass
index 5091f8e..499fa43 100644 (file)
@@ -1,11 +1,29 @@
 from django.contrib import admin
+from models import *
 
-from models import ServiceTicket, LoginTicket
 
 class ServiceTicketAdmin(admin.ModelAdmin):
-    pass
-admin.site.register(ServiceTicket, ServiceTicketAdmin)
+    list_display = ('user', 'service', 'created')
+    list_filter = ('created',)
+
+class ProxyTicketAdmin(admin.ModelAdmin):
+    list_display = ('user', 'service', 'created')
+    list_filter = ('created',)
 
 class LoginTicketAdmin(admin.ModelAdmin):
-    pass
-admin.site.register(LoginTicket, LoginTicketAdmin)
\ No newline at end of file
+    list_display = ('ticket', 'created')
+    list_filter = ('created',)
+
+class ProxyGrantingTicketAdmin(admin.ModelAdmin):
+    list_display = ('ticket', 'created')
+    list_filter = ('created',)
+
+class ProxyGrantingTicketIOUAdmin(admin.ModelAdmin):
+    list_display = ('ticket', 'created')
+    list_filter = ('created',)
+
+admin.site.register(ServiceTicket, ServiceTicketAdmin)
+admin.site.register(ProxyTicket, ProxyTicketAdmin)
+admin.site.register(LoginTicket, LoginTicketAdmin)
+admin.site.register(ProxyGrantingTicket, ProxyGrantingTicketAdmin)
+admin.site.register(ProxyGrantingTicketIOU, ProxyGrantingTicketIOUAdmin)
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)
diff --git a/cas_provider/fixtures/cas_users.json b/cas_provider/fixtures/cas_users.json
new file mode 100644 (file)
index 0000000..f31d5cb
--- /dev/null
@@ -0,0 +1,130 @@
+[
+  {
+    "pk": 1, 
+    "model": "auth.group", 
+    "fields": {
+      "name": "editor", 
+      "permissions": []
+    }
+  }, 
+  {
+    "pk": 2, 
+    "model": "auth.group", 
+    "fields": {
+      "name": "author", 
+      "permissions": []
+    }
+  }, 
+  {
+    "pk": 1, 
+    "model": "auth.user", 
+    "fields": {
+      "username": "root", 
+      "first_name": "", 
+      "last_name": "", 
+      "is_active": true, 
+      "is_superuser": true, 
+      "is_staff": true, 
+      "last_login": "2011-04-24 11:29:11", 
+      "groups": [], 
+      "user_permissions": [], 
+      "password": "sha1$602c5$ba8608296f6bfcb352e978084b337a90d586ecc3", 
+      "email": "root@example.com", 
+      "date_joined": "2010-07-04 13:33:14"
+    }
+  }, 
+  {
+    "pk": 26, 
+    "model": "auth.user", 
+    "fields": {
+      "username": "active", 
+      "first_name": "", 
+      "last_name": "", 
+      "is_active": true, 
+      "is_superuser": false, 
+      "is_staff": false, 
+      "last_login": "2011-04-01 12:42:53", 
+      "groups": [], 
+      "user_permissions": [], 
+      "password": "sha1$7dfb4$d19f8340a01b597089dfde6dc17bc5288c1f863e", 
+      "email": "active@example.com", 
+      "date_joined": "2011-04-01 11:12:45"
+    }
+  }, 
+  {
+    "pk": 30, 
+    "model": "auth.user", 
+    "fields": {
+      "username": "author", 
+      "first_name": "", 
+      "last_name": "", 
+      "is_active": true, 
+      "is_superuser": false, 
+      "is_staff": true, 
+      "last_login": "2011-04-24 11:32:16", 
+      "groups": [
+        2
+      ], 
+      "user_permissions": [], 
+      "password": "sha1$6c580$01509bea19e3ade9f1bcf303205a7cb10ce6762d", 
+      "email": "", 
+      "date_joined": "2011-04-24 11:32:16"
+    }
+  }, 
+  {
+    "pk": 29, 
+    "model": "auth.user", 
+    "fields": {
+      "username": "editor", 
+      "first_name": "", 
+      "last_name": "", 
+      "is_active": true, 
+      "is_superuser": false, 
+      "is_staff": true, 
+      "last_login": "2011-04-24 11:31:50", 
+      "groups": [
+        1
+      ], 
+      "user_permissions": [], 
+      "password": "sha1$3be01$b6aa05c61fc52edae3055c55e160d4cfd4756d91", 
+      "email": "editor@exapmle.com", 
+      "date_joined": "2011-04-24 11:31:50"
+    }
+  }, 
+  {
+    "pk": 27, 
+    "model": "auth.user", 
+    "fields": {
+      "username": "nonactive", 
+      "first_name": "", 
+      "last_name": "", 
+      "is_active": false, 
+      "is_superuser": false, 
+      "is_staff": false, 
+      "last_login": "2011-04-24 11:31:00", 
+      "groups": [], 
+      "user_permissions": [], 
+      "password": "sha1$0bf10$d60f146d15e4fe3cb0de5a607a17902d0f63a95c", 
+      "email": "", 
+      "date_joined": "2011-04-24 11:31:00"
+    }
+  }, 
+  {
+    "pk": 28, 
+    "model": "auth.user", 
+    "fields": {
+      "username": "staff", 
+      "first_name": "", 
+      "last_name": "", 
+      "is_active": true, 
+      "is_superuser": false, 
+      "is_staff": true, 
+      "last_login": "2011-04-24 11:31:26", 
+      "groups": [], 
+      "user_permissions": [], 
+      "password": "sha1$5df85$bb4c1894a866fb86465d28831000af20316233d5", 
+      "email": "staff@example.com", 
+      "date_joined": "2011-04-24 11:31:26"
+    }
+  }
+]
\ No newline at end of file
index d9974a2..8ec7a22 100644 (file)
@@ -1,7 +1,14 @@
 from django import forms
+from django.conf import settings
+from django.contrib.auth import authenticate
+from django.contrib.auth.forms import AuthenticationForm
+from django.forms import ValidationError
+from django.utils.translation import ugettext_lazy as _
+from models import LoginTicket
+import datetime
 
 
-class LoginForm(forms.Form):
+class LoginForm(AuthenticationForm):
     email = forms.CharField(widget=forms.TextInput(attrs={'autofocus': 'autofocus',
                                                           'max_length': '255'}))
     password = forms.CharField(widget=forms.PasswordInput)
diff --git a/cas_provider/locale/ru/LC_MESSAGES/django.mo b/cas_provider/locale/ru/LC_MESSAGES/django.mo
new file mode 100644 (file)
index 0000000..c02ed63
Binary files /dev/null and b/cas_provider/locale/ru/LC_MESSAGES/django.mo differ
diff --git a/cas_provider/locale/ru/LC_MESSAGES/django.po b/cas_provider/locale/ru/LC_MESSAGES/django.po
new file mode 100644 (file)
index 0000000..42a8506
--- /dev/null
@@ -0,0 +1,71 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# Alex Kamedov <alex@kamedov.ru>, 2011.
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2011-04-24 18:51+0600\n"
+"PO-Revision-Date: 2011-04-07 12:01+0600\n"
+"Last-Translator: Volf <alex@kamedov.ru>\n"
+"Language-Team: delux\n"
+"Language: ru\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
+"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
+"X-Generator: Virtaal 0.6.1\n"
+
+#: forms.py:14
+msgid "username"
+msgstr "имя пользователя"
+
+#: forms.py:15
+msgid "password"
+msgstr "пароль"
+
+#: forms.py:30
+msgid "Login ticket expired. Please try again."
+msgstr "Истек срок действия билета входа. Пожалуйста, попробуйте еще раз."
+
+#: forms.py:38
+msgid "Incorrect username and/or password."
+msgstr "Неверное имя пользователя и/или пароль."
+
+#: forms.py:40
+msgid "This account is disabled."
+msgstr "Эта учетная запись отключена."
+
+#: models.py:14
+msgid "ticket"
+msgstr "билет"
+
+#: models.py:15
+msgid "created"
+msgstr "создан"
+
+#: models.py:34
+msgid "user"
+msgstr "пользователь"
+
+#: models.py:35
+msgid "service"
+msgstr "сервис"
+
+#: models.py:40
+msgid "Service Ticket"
+msgstr "Билет для сервиса"
+
+#: models.py:41
+msgid "Service Tickets"
+msgstr "Билеты для сервисов"
+
+#: models.py:59
+msgid "Login Ticket"
+msgstr "Билет для входа"
+
+#: models.py:60
+msgid "Login Tickets"
+msgstr "Билеты для входа"
index 772fdcb..401bec1 100644 (file)
@@ -8,7 +8,6 @@ contains the actual logic for determining which accounts are deleted.
 """
 
 from django.core.management.base import NoArgsCommand
-from django.core.management.base import CommandError
 from django.conf import settings
 
 import datetime
diff --git a/cas_provider/migrations/0001_initial.py b/cas_provider/migrations/0001_initial.py
new file mode 100644 (file)
index 0000000..74770af
--- /dev/null
@@ -0,0 +1,92 @@
+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        
+        # Adding model 'ServiceTicket'
+        db.create_table('cas_provider_serviceticket', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('ticket', self.gf('django.db.models.fields.CharField')(max_length=32)),
+            ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)),
+            ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
+            ('service', self.gf('django.db.models.fields.URLField')(max_length=200)),
+        ))
+        db.send_create_signal('cas_provider', ['ServiceTicket'])
+
+        # Adding model 'LoginTicket'
+        db.create_table('cas_provider_loginticket', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('ticket', self.gf('django.db.models.fields.CharField')(max_length=32)),
+            ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)),
+        ))
+        db.send_create_signal('cas_provider', ['LoginTicket'])
+
+
+    def backwards(self, orm):
+        
+        # Deleting model 'ServiceTicket'
+        db.delete_table('cas_provider_serviceticket')
+
+        # Deleting model 'LoginTicket'
+        db.delete_table('cas_provider_loginticket')
+
+
+    models = {
+        'auth.group': {
+            'Meta': {'object_name': 'Group'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+            'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+        },
+        'auth.permission': {
+            'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+            'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+        },
+        'auth.user': {
+            'Meta': {'object_name': 'User'},
+            'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+        },
+        'cas_provider.loginticket': {
+            'Meta': {'object_name': 'LoginTicket'},
+            'created': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ticket': ('django.db.models.fields.CharField', [], {'max_length': '32'})
+        },
+        'cas_provider.serviceticket': {
+            'Meta': {'object_name': 'ServiceTicket'},
+            'created': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'service': ('django.db.models.fields.URLField', [], {'max_length': '200'}),
+            'ticket': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+        },
+        'contenttypes.contenttype': {
+            'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+        }
+    }
+
+    complete_apps = ['cas_provider']
diff --git a/cas_provider/migrations/0002_auto__add_proxygrantingticket__add_proxyticket__add_proxygrantingticke.py b/cas_provider/migrations/0002_auto__add_proxygrantingticket__add_proxyticket__add_proxygrantingticke.py
new file mode 100644 (file)
index 0000000..46247aa
--- /dev/null
@@ -0,0 +1,125 @@
+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        
+        # Adding model 'ProxyGrantingTicket'
+        db.create_table('cas_provider_proxygrantingticket', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('ticket', self.gf('django.db.models.fields.CharField')(max_length=32)),
+            ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)),
+            ('serviceTicket', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['cas_provider.ServiceTicket'], null=True)),
+            ('pgtiou', self.gf('django.db.models.fields.CharField')(max_length=256)),
+            ('targetService', self.gf('django.db.models.fields.URLField')(max_length=200)),
+        ))
+        db.send_create_signal('cas_provider', ['ProxyGrantingTicket'])
+
+        # Adding model 'ProxyTicket'
+        db.create_table('cas_provider_proxyticket', (
+            ('serviceticket_ptr', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['cas_provider.ServiceTicket'], unique=True, primary_key=True)),
+            ('proxyGrantingTicket', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['cas_provider.ProxyGrantingTicket'])),
+        ))
+        db.send_create_signal('cas_provider', ['ProxyTicket'])
+
+        # Adding model 'ProxyGrantingTicketIOU'
+        db.create_table('cas_provider_proxygrantingticketiou', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('ticket', self.gf('django.db.models.fields.CharField')(max_length=32)),
+            ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)),
+            ('proxyGrantingTicket', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['cas_provider.ProxyGrantingTicket'])),
+        ))
+        db.send_create_signal('cas_provider', ['ProxyGrantingTicketIOU'])
+
+
+    def backwards(self, orm):
+        
+        # Deleting model 'ProxyGrantingTicket'
+        db.delete_table('cas_provider_proxygrantingticket')
+
+        # Deleting model 'ProxyTicket'
+        db.delete_table('cas_provider_proxyticket')
+
+        # Deleting model 'ProxyGrantingTicketIOU'
+        db.delete_table('cas_provider_proxygrantingticketiou')
+
+
+    models = {
+        'auth.group': {
+            'Meta': {'object_name': 'Group'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+            'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+        },
+        'auth.permission': {
+            'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+            'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+        },
+        'auth.user': {
+            'Meta': {'object_name': 'User'},
+            'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+        },
+        'cas_provider.loginticket': {
+            'Meta': {'object_name': 'LoginTicket'},
+            'created': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ticket': ('django.db.models.fields.CharField', [], {'max_length': '32'})
+        },
+        'cas_provider.proxygrantingticket': {
+            'Meta': {'object_name': 'ProxyGrantingTicket'},
+            'created': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'pgtiou': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
+            'serviceTicket': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['cas_provider.ServiceTicket']", 'null': 'True'}),
+            'targetService': ('django.db.models.fields.URLField', [], {'max_length': '200'}),
+            'ticket': ('django.db.models.fields.CharField', [], {'max_length': '32'})
+        },
+        'cas_provider.proxygrantingticketiou': {
+            'Meta': {'object_name': 'ProxyGrantingTicketIOU'},
+            'created': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'proxyGrantingTicket': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['cas_provider.ProxyGrantingTicket']"}),
+            'ticket': ('django.db.models.fields.CharField', [], {'max_length': '32'})
+        },
+        'cas_provider.proxyticket': {
+            'Meta': {'object_name': 'ProxyTicket', '_ormbases': ['cas_provider.ServiceTicket']},
+            'proxyGrantingTicket': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['cas_provider.ProxyGrantingTicket']"}),
+            'serviceticket_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['cas_provider.ServiceTicket']", 'unique': 'True', 'primary_key': 'True'})
+        },
+        'cas_provider.serviceticket': {
+            'Meta': {'object_name': 'ServiceTicket'},
+            'created': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'service': ('django.db.models.fields.URLField', [], {'max_length': '200'}),
+            'ticket': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+        },
+        'contenttypes.contenttype': {
+            'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+        }
+    }
+
+    complete_apps = ['cas_provider']
diff --git a/cas_provider/migrations/0003_auto__del_field_proxygrantingticket_targetService.py b/cas_provider/migrations/0003_auto__del_field_proxygrantingticket_targetService.py
new file mode 100644 (file)
index 0000000..46b5124
--- /dev/null
@@ -0,0 +1,94 @@
+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        
+        # Deleting field 'ProxyGrantingTicket.targetService'
+        db.delete_column('cas_provider_proxygrantingticket', 'targetService')
+
+
+    def backwards(self, orm):
+        
+        # Adding field 'ProxyGrantingTicket.targetService'
+        db.add_column('cas_provider_proxygrantingticket', 'targetService', self.gf('django.db.models.fields.URLField')(default='http://not.used', max_length=200), keep_default=False)
+
+
+    models = {
+        'auth.group': {
+            'Meta': {'object_name': 'Group'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+            'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+        },
+        'auth.permission': {
+            'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+            'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+        },
+        'auth.user': {
+            'Meta': {'object_name': 'User'},
+            'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+        },
+        'cas_provider.loginticket': {
+            'Meta': {'object_name': 'LoginTicket'},
+            'created': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'ticket': ('django.db.models.fields.CharField', [], {'max_length': '32'})
+        },
+        'cas_provider.proxygrantingticket': {
+            'Meta': {'object_name': 'ProxyGrantingTicket'},
+            'created': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'pgtiou': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
+            'serviceTicket': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['cas_provider.ServiceTicket']", 'null': 'True'}),
+            'ticket': ('django.db.models.fields.CharField', [], {'max_length': '32'})
+        },
+        'cas_provider.proxygrantingticketiou': {
+            'Meta': {'object_name': 'ProxyGrantingTicketIOU'},
+            'created': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'proxyGrantingTicket': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['cas_provider.ProxyGrantingTicket']"}),
+            'ticket': ('django.db.models.fields.CharField', [], {'max_length': '32'})
+        },
+        'cas_provider.proxyticket': {
+            'Meta': {'object_name': 'ProxyTicket', '_ormbases': ['cas_provider.ServiceTicket']},
+            'proxyGrantingTicket': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['cas_provider.ProxyGrantingTicket']"}),
+            'serviceticket_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['cas_provider.ServiceTicket']", 'unique': 'True', 'primary_key': 'True'})
+        },
+        'cas_provider.serviceticket': {
+            'Meta': {'object_name': 'ServiceTicket'},
+            'created': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'service': ('django.db.models.fields.URLField', [], {'max_length': '200'}),
+            'ticket': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+        },
+        'contenttypes.contenttype': {
+            'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+        }
+    }
+
+    complete_apps = ['cas_provider']
diff --git a/cas_provider/migrations/__init__.py b/cas_provider/migrations/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
index 39641a1..8306d3c 100644 (file)
@@ -1,20 +1,99 @@
-from django.db import models
 from django.contrib.auth.models import User
+from django.db import models
+from django.utils.translation import ugettext_lazy as _
+from random import Random
+import string
+import urllib
+import urlparse
 
+if hasattr(urlparse, 'parse_qs'):
+    parse_qs = urlparse.parse_qs
+else:
+    # Python <2.6 compatibility
+    from cgi import parse_qs
 
-class ServiceTicket(models.Model):
-    user = models.ForeignKey(User)
-    service = models.URLField(verify_exists=False)
-    ticket = models.CharField(max_length=256)
-    created = models.DateTimeField(auto_now=True)
-    
-    def __unicode__(self):
-        return "%s (%s) - %s" % (self.user.username, self.service, self.created)
+__all__ = ['ServiceTicket', 'LoginTicket', 'ProxyGrantingTicket', 'ProxyTicket', 'ProxyGrantingTicketIOU']
+
+class BaseTicket(models.Model):
+    ticket = models.CharField(_('ticket'), max_length=32)
+    created = models.DateTimeField(_('created'), auto_now=True)
 
+    class Meta:
+        abstract = True
+
+    def __init__(self, *args, **kwargs):
+        if 'ticket' not in kwargs:
+            kwargs['ticket'] = self._generate_ticket()
+        super(BaseTicket, self).__init__(*args, **kwargs)
 
-class LoginTicket(models.Model):
-    ticket = models.CharField(max_length=32)
-    created = models.DateTimeField(auto_now=True)
-    
     def __unicode__(self):
-        return "%s - %s" % (self.ticket, self.created)
+        return self.ticket
+
+    def _generate_ticket(self, length=ticket.max_length, chars=string.ascii_letters + string.digits):
+        """ Generates a random string of the requested length. Used for creation of tickets. """
+        return u"%s-%s" % (self.prefix, ''.join(Random().sample(chars, length - (len(self.prefix) + 1))))
+
+
+class ServiceTicket(BaseTicket):
+    user = models.ForeignKey(User, verbose_name=_('user'))
+    service = models.URLField(_('service'), verify_exists=False)
+
+    prefix = 'ST'
+
+    class Meta:
+        verbose_name = _('Service Ticket')
+        verbose_name_plural = _('Service Tickets')
+
+    def get_redirect_url(self):
+        parsed = urlparse.urlparse(self.service)
+        query = parse_qs(parsed.query)
+        query['ticket'] = [self.ticket]
+        query = [((k, v) if len(v) > 1 else (k, v[0])) for k, v in query.iteritems()]
+        parsed = urlparse.ParseResult(parsed.scheme, parsed.netloc,
+                                      parsed.path, parsed.params,
+                                      urllib.urlencode(query), parsed.fragment)
+        return parsed.geturl()
+
+
+class LoginTicket(BaseTicket):
+    prefix = 'LT'
+
+    class Meta:
+        verbose_name = _('Login Ticket')
+        verbose_name_plural = _('Login Tickets')
+
+
+class ProxyGrantingTicket(BaseTicket):
+    serviceTicket = models.ForeignKey(ServiceTicket, null=True)
+    pgtiou = models.CharField(max_length=256, verbose_name=_('PGTiou'))
+    prefix = 'PGT'
+
+    def __init__(self, *args, **kwargs):
+        if 'pgtiou' not in kwargs:
+            kwargs['pgtiou'] = u"PGTIOU-%s" % (''.join(Random().sample(string.ascii_letters + string.digits, 50)))
+        super(ProxyGrantingTicket, self).__init__(*args, **kwargs)
+
+    class Meta:
+        verbose_name = _('Proxy Granting Ticket')
+        verbose_name_plural = _('Proxy Granting Tickets')
+
+
+class ProxyTicket(ServiceTicket):
+    proxyGrantingTicket = models.ForeignKey(ProxyGrantingTicket, verbose_name=_('Proxy Granting Ticket'))
+
+    prefix = 'PT'
+
+    class Meta:
+        verbose_name = _('Proxy Ticket')
+        verbose_name_plural = _('Proxy Tickets')
+
+
+class ProxyGrantingTicketIOU(BaseTicket):
+    proxyGrantingTicket = models.ForeignKey(ProxyGrantingTicket, verbose_name=_('Proxy Granting Ticket'))
+
+    prefix = 'PGTIOU'
+
+    class Meta:
+        verbose_name = _('Proxy Granting Ticket IOU')
+        verbose_name_plural = _('Proxy Granting Tickets IOU')
+
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 %}
diff --git a/cas_provider/templates/cas/warn.html b/cas_provider/templates/cas/warn.html
new file mode 100644 (file)
index 0000000..ca9e561
--- /dev/null
@@ -0,0 +1,15 @@
+{% extends "base.html" %}
+
+{% block title %}
+Warning
+{% endblock %}
+
+{% block content %}
+  <form action='{% url cas_login %}' method='get'>
+    <fieldset>
+      <legend>Confirm to log in to {{ service }}</legend>
+         <input type="hidden" name="service" value="{{ service }}">
+      <p><input type="submit" value="Login"/></p>
+    </fieldset>
+  </form>
+{% endblock %}
diff --git a/cas_provider/tests.py b/cas_provider/tests.py
new file mode 100644 (file)
index 0000000..46ba3ca
--- /dev/null
@@ -0,0 +1,351 @@
+import StringIO
+import urllib2
+from xml import etree
+from xml.etree import ElementTree
+import cas_provider
+from cas_provider.attribute_formatters import CAS, NSMAP
+from cas_provider.models import ServiceTicket
+from cas_provider.views import _cas2_sucess_response, INVALID_TICKET, _cas2_error_response, generate_proxy_granting_ticket
+from django.contrib.auth.models import User, UserManager
+from django.core.urlresolvers import reverse
+from django.test import TestCase
+from urlparse import urlparse, parse_qsl, parse_qs
+from django.conf import settings
+
+
+
+
+dummy_urlopen_url = None
+
+
+def dummy_urlopen(url):
+    cas_provider.tests.dummy_urlopen_url = url
+    pass
+
+class ViewsTest(TestCase):
+
+    fixtures = ['cas_users', ]
+
+
+
+    def setUp(self):
+        self.service = 'http://example.com/'
+
+
+    def test_successful_login_with_proxy(self):
+        urllib2.urlopen = dummy_urlopen # monkey patching urllib2.urlopen so that the testcase doesnt really opens a url
+        proxyTarget = "http://my.sweet.service"
+
+        response = self._login_user('root', '123')
+        response = self._validate_cas2(response, True, proxyTarget )
+
+        # Test: I'm acting as the service that will call another service
+        # Step 1: Get the proxy granting ticket
+        responseXml = ElementTree.parse(StringIO.StringIO(response.content))
+        auth_success = responseXml.find(CAS + 'authenticationSuccess', namespaces=NSMAP)
+        pgt = auth_success.find(CAS + "proxyGrantingTicket", namespaces=NSMAP)
+        user = auth_success.find(CAS + "user", namespaces=NSMAP)
+        self.assertEqual('root', user.text)
+        self.assertIsNotNone(pgt.text)
+        self.assertTrue(pgt.text.startswith('PGTIOU'))
+
+        pgtId = parse_qs(urlparse(cas_provider.tests.dummy_urlopen_url).query)['pgtId']
+
+        #Step 2: Get the actual proxy ticket
+        proxyTicketResponse = self.client.get(reverse('proxy'), {'targetService': proxyTarget, 'pgt': pgtId}, follow=False)
+        proxyTicketResponseXml = ElementTree.parse(StringIO.StringIO(proxyTicketResponse.content))
+        self.assertIsNotNone(proxyTicketResponseXml.find(CAS + "proxySuccess", namespaces=NSMAP))
+        self.assertIsNotNone(proxyTicketResponseXml.find(CAS + "proxySuccess/cas:proxyTicket", namespaces=NSMAP))
+        proxyTicket = proxyTicketResponseXml.find(CAS + "proxySuccess/cas:proxyTicket", namespaces=NSMAP);
+
+        #Step 3: I have the proxy ticket I can talk to some other backend service as the currently logged in user!
+        proxyValidateResponse = self.client.get(reverse('cas_proxy_validate'), {'ticket': proxyTicket.text, 'service': proxyTarget})
+        proxyValidateResponseXml = ElementTree.parse(StringIO.StringIO(proxyValidateResponse.content))
+
+        auth_success_2 = proxyValidateResponseXml.find(CAS + 'authenticationSuccess', namespaces=NSMAP)
+        user_2 = auth_success.find(CAS + "user", namespaces=NSMAP)
+        proxies_1  = auth_success_2.find(CAS + "proxies")
+        self.assertIsNone(proxies_1) # there are no proxies. I am issued by a Service Ticket
+        
+        self.assertEqual(user.text, user_2.text)
+
+
+    def test_successful_proxy_chaining(self):
+        urllib2.urlopen = dummy_urlopen # monkey patching urllib2.urlopen so that the testcase doesnt really opens a url
+        proxyTarget_1 = "http://my.sweet.service_1"
+        proxyTarget_2 = "http://my.sweet.service_2"
+
+        response = self._login_user('root', '123')
+        response = self._validate_cas2(response, True, proxyTarget_1 )
+
+        # Test: I'm acting as the service that will call another service
+        # Step 1: Get the proxy granting ticket
+        responseXml = ElementTree.parse(StringIO.StringIO(response.content))
+        auth_success_1 = responseXml.find(CAS + 'authenticationSuccess', namespaces=NSMAP)
+        pgt_1 = auth_success_1.find(CAS + "proxyGrantingTicket", namespaces=NSMAP)
+        user_1 = auth_success_1.find(CAS + "user", namespaces=NSMAP)
+        self.assertEqual('root', user_1.text)
+        self.assertIsNotNone(pgt_1.text)
+        self.assertTrue(pgt_1.text.startswith('PGTIOU'))
+
+        pgtId_1 = parse_qs(urlparse(cas_provider.tests.dummy_urlopen_url).query)['pgtId']
+
+        #Step 2: Get the actual proxy ticket
+        proxyTicketResponse_1 = self.client.get(reverse('proxy'), {'targetService': proxyTarget_1, 'pgt': pgtId_1}, follow=False)
+        proxyTicketResponseXml_1 = ElementTree.parse(StringIO.StringIO(proxyTicketResponse_1.content))
+        self.assertIsNotNone(proxyTicketResponseXml_1.find(CAS + "proxySuccess", namespaces=NSMAP))
+        self.assertIsNotNone(proxyTicketResponseXml_1.find(CAS + "proxySuccess/cas:proxyTicket", namespaces=NSMAP))
+        proxyTicket_1 = proxyTicketResponseXml_1.find(CAS + "proxySuccess/cas:proxyTicket", namespaces=NSMAP);
+
+        #Step 3: I'm backend service 1 - I have the proxy ticket - I want to talk to back service 2
+        #
+        proxyValidateResponse_1 = self.client.get(reverse('cas_proxy_validate'), {'ticket': proxyTicket_1.text, 'service': proxyTarget_1, 'pgtUrl': proxyTarget_2})
+        proxyValidateResponseXml_1 = ElementTree.parse(StringIO.StringIO(proxyValidateResponse_1.content))
+
+        auth_success_2 = proxyValidateResponseXml_1.find(CAS + 'authenticationSuccess', namespaces=NSMAP)
+        user_2 = auth_success_2.find(CAS + "user", namespaces=NSMAP)
+
+        proxies_1  = auth_success_2.find(CAS + "proxies")
+        self.assertIsNone(proxies_1) # there are no proxies. I am issued by a Service Ticket
+        self.assertIsNotNone(auth_success_2)
+        self.assertEqual('root', user_2.text)
+
+        pgt_2 = auth_success_2.find(CAS + "proxyGrantingTicket", namespaces=NSMAP)
+        user = auth_success_2.find(CAS + "user", namespaces=NSMAP)
+        self.assertEqual('root', user.text)
+        self.assertIsNotNone(pgt_2.text)
+        self.assertTrue(pgt_2.text.startswith('PGTIOU'))
+
+        pgtId_2 = parse_qs(urlparse(cas_provider.tests.dummy_urlopen_url).query)['pgtId']
+
+        #Step 4: Get the second proxy ticket
+        proxyTicketResponse_2 = self.client.get(reverse('proxy'), {'targetService': proxyTarget_2, 'pgt': pgtId_2})
+        proxyTicketResponseXml_2 = ElementTree.parse(StringIO.StringIO(proxyTicketResponse_2.content))
+        self.assertIsNotNone(proxyTicketResponseXml_2.find(CAS + "proxySuccess", namespaces=NSMAP))
+        self.assertIsNotNone(proxyTicketResponseXml_2.find(CAS + "proxySuccess/cas:proxyTicket", namespaces=NSMAP))
+        proxyTicket_2 = proxyTicketResponseXml_2.find(CAS + "proxySuccess/cas:proxyTicket", namespaces=NSMAP)
+
+        proxyValidateResponse_3 = self.client.get(reverse('cas_proxy_validate'), {'ticket': proxyTicket_2.text, 'service': proxyTarget_2, 'pgtUrl': None})
+        proxyValidateResponseXml_3 = ElementTree.parse(StringIO.StringIO(proxyValidateResponse_3.content))
+
+        auth_success_3 = proxyValidateResponseXml_3.find(CAS + 'authenticationSuccess', namespaces=NSMAP)
+        user_3 = auth_success_3.find(CAS + "user", namespaces=NSMAP)
+
+        proxies_3  = auth_success_3.find(CAS + "proxies")
+        self.assertIsNotNone(proxies_3) # there should be a proxy. I am issued by a Proxy Ticket
+        proxy_3 = proxies_3.find(CAS + "proxy", namespaces=NSMAP)
+        self.assertEqual(proxyTarget_1, proxy_3.text )
+
+        self.assertIsNotNone(auth_success_2)
+        self.assertEqual('root', user_3.text)
+
+
+
+    
+    def test_succeessful_login(self):
+        response = self._login_user('root', '123')
+        self._validate_cas1(response, True)
+
+        response = self.client.get(reverse('cas_login'), {'service': self.service}, follow=False)
+        self.assertEqual(response.status_code, 302)
+        self.assertTrue(response['location'].startswith('%s?ticket=' % self.service))
+
+        response = self.client.get(reverse('cas_login'), follow=False)
+        self.assertEqual(response.status_code, 302)
+        self.assertTrue(response['location'].startswith('http://testserver/'))
+
+        response = self.client.get(response['location'], follow=False)
+        self.assertIn(response.status_code, [302, 200])
+
+        response = self.client.get(reverse('cas_login'), {'service': self.service, 'warn': True}, follow=False)
+        self.assertEqual(response.status_code, 200)
+        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)
+
+        self._cas_logout()
+
+        response = self.client.get(reverse('cas_login'), follow=False)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.context['user'].is_anonymous(), True)
+
+
+    def test_broken_pwd(self):
+        self._fail_login('root', '321')
+
+    def test_broken_username(self):
+        self._fail_login('notroot', '123')
+
+    def test_nonactive_user_login(self):
+        self._fail_login('nonactive', '123')
+
+    def test_cas2_success_validate(self):
+        response = self._login_user('root', '123')
+        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._login_user(user, pwd)
+            self._validate_cas2(response, False)
+
+
+    def test_generate_proxy_granting_ticket(self):
+        urllib2.urlopen = dummy_urlopen # monkey patching urllib2.urlopen so that the testcase doesnt really opens a url
+        url = 'http://my.call.back/callhere'
+
+        user = User.objects.get(username = 'root')
+        st = ServiceTicket.objects.create(user = user )
+        pgt = generate_proxy_granting_ticket(url, st)
+        self.assertIsNotNone(pgt)
+
+        calledUrl = cas_provider.tests.dummy_urlopen_url
+        parsedUrl = urlparse(calledUrl)
+        params = parse_qs(parsedUrl.query)
+        self.assertIsNotNone(params['pgtId'])
+        self.assertIsNotNone(params['pgtIou'])
+
+
+
+    def _fail_login(self, username, password):
+        response = self._login_user(username, password)
+        self._validate_cas1(response, False)
+
+        response = self.client.get(reverse('cas_login'), {'service': self.service}, follow=False)
+        self.assertEqual(response.status_code, 200)
+        response = self.client.get(reverse('cas_login'), follow=False)
+        self.assertEqual(response.status_code, 200)
+
+
+
+    def _login_user(self, username, password):
+        self.username = username
+        response = self.client.get(reverse('cas_login'), {'service': self.service})
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'cas/login.html')
+        form = response.context['form']
+        service = form['service'].value()
+        return self.client.post(reverse('cas_login'), {
+            'username': username,
+            'password': password,
+            'lt': form['lt'].value(),
+            'service': service
+        }, follow=False)
+
+
+    def _validate_cas1(self, response, is_correct=True):
+        if is_correct:
+            self.assertEqual(response.status_code, 302)
+            self.assertTrue(response.has_header('location'))
+            location = urlparse(response['location'])
+            ticket = location.query.split('=')[1]
+
+            response = self.client.get(reverse('cas_validate'), {'ticket': ticket, 'service': self.service}, follow=False)
+            self.assertEqual(response.status_code, 200)
+            self.assertEqual(unicode(response.content), u'yes\n%s\n' % self.username)
+        else:
+            self.assertEqual(response.status_code, 200)
+            self.assertEqual(len(response.context['form'].errors), 1)
+
+            response = self.client.get(reverse('cas_validate'), {'ticket': 'ST-12312312312312312312312', 'service': self.service}, follow=False)
+            self.assertEqual(response.status_code, 200)
+            self.assertEqual(response.content, u'no\n\n')
+
+
+    def _validate_cas2(self, response, is_correct=True, pgtUrl = None):
+        if is_correct:
+            self.assertEqual(response.status_code, 302)
+            self.assertTrue(response.has_header('location'))
+            location = urlparse(response['location'])
+            ticket = location.query.split('=')[1]
+            if pgtUrl:
+                response = self.client.get(reverse('cas_service_validate'), {'ticket': ticket, 'service': self.service, 'pgtUrl': pgtUrl}, follow=False)
+            else:
+                response = self.client.get(reverse('cas_service_validate'), {'ticket': ticket, 'service': self.service}, follow=False)
+            self.assertEqual(response.status_code, 200)
+        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):
+
+    fixtures = ['cas_users.json', ]
+
+    def setUp(self):
+        self.user = User.objects.get(username='root')
+
+    def test_redirects(self):
+        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 f91786b..154aac0 100644 (file)
@@ -1,11 +1,13 @@
-from django.conf.urls.defaults import *
+from django.conf.urls.defaults import patterns, url
 
-from views import *
 
-urlpatterns = patterns('',
-                       url(r'^login/', login),
-                       url(r'^socialauth-login/$', socialauth_login),
-                       url(r'^validate/', validate),
-                       url(r'^logout/', logout),
-                       url(r'^login/merge/', login, {'merge': True, 'template_name': 'cas/merge.html'})
-                       )
+urlpatterns = patterns('cas_provider.views',
+    url(r'^login/merge/', 'login', {'merge': True, 'template_name': 'cas/merge.html'})
+    url(r'^login/?$', 'login', name='cas_login'),
+    url(r'^socialauth-login/$', 'socialauth_login', name='cas_socialauth_login'),
+    url(r'^validate/?$', 'validate', name='cas_validate'),
+    url(r'^proxy/?$', 'proxy', name='proxy'),
+    url(r'^serviceValidate/?$', 'service_validate', name='cas_service_validate'),
+    url(r'^proxyValidate/?$', 'proxy_validate', name='cas_proxy_validate'),
+    url(r'^logout/?$', 'logout', name='cas_logout'),
+)
diff --git a/cas_provider/utils.py b/cas_provider/utils.py
deleted file mode 100644 (file)
index 04a5c12..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-from random import Random
-import string
-
-from models import ServiceTicket, LoginTicket
-
-def _generate_string(length=8, chars=string.ascii_letters + string.digits):
-    """ Generates a random string of the requested length. Used for creation of tickets. """
-    return ''.join(Random().sample(chars, length))
-
-def create_service_ticket(user, service):
-    """ Creates a new service ticket for the specified user and service.
-        Uses _generate_string.
-    """
-    ticket_string = 'ST-' + _generate_string(29) # Total ticket length = 29 + 3 = 32
-    ticket = ServiceTicket(service=service, user=user, ticket=ticket_string)
-    ticket.save()
-    return ticket
-
-def create_login_ticket():
-    """ Creates a new login ticket for the login form. Uses _generate_string. """
-    ticket_string = 'LT-' + _generate_string(29)
-    ticket = LoginTicket(ticket=ticket_string)
-    ticket.save()
-    return ticket_string
\ No newline at end of file
index 5ba62e6..a0847b9 100644 (file)
@@ -2,31 +2,51 @@ import logging
 logger = logging.getLogger('cas_provider.views')
 import urllib
 
+import logging
+from urllib import urlencode
+import urllib2
+import urlparse
+
 from django.http import HttpResponse, 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.shortcuts import render_to_response
 from django.template import RequestContext
 from django.contrib.auth import authenticate
-from django.contrib.auth import login as auth_login, logout as auth_logout
 from django.core.urlresolvers import reverse
 
-from forms import LoginForm, MergeLoginForm
-from models import ServiceTicket
-from utils import create_service_ticket
-from exceptions import SameEmailMismatchedPasswords
+from lxml import etree
+from cas_provider.attribute_formatters import NSMAP, CAS
+from cas_provider.models import ProxyGrantingTicket, ProxyTicket
+from cas_provider.models import ServiceTicket
+
+from cas_provider.exceptions import SameEmailMismatchedPasswords
+from cas_provider.forms import LoginForm, MergeLoginForm
 
 from . import signals
 
-__all__ = ['login', 'validate', 'logout']
+__all__ = ['login', 'validate', 'logout', 'service_validate']
 
+INVALID_TICKET = 'INVALID_TICKET'
+INVALID_SERVICE = 'INVALID_SERVICE'
+INVALID_REQUEST = 'INVALID_REQUEST'
+INTERNAL_ERROR = 'INTERNAL_ERROR'
+
+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 _build_service_url(service, ticket):
-    if service.find('?') == -1:
-        return service + '?ticket=' + ticket
-    else:
-        return service + '&ticket=' + ticket
 
+logger = logging.getLogger(__name__)
 
-def login(request, template_name='cas/login.html', success_redirect='/account/', **kwargs):
+
+def login(request, template_name='cas/login.html',
+          success_redirect=settings.LOGIN_REDIRECT_URL,
+          warn_template_name='cas/warn.html', **kwargs):
     merge = kwargs.get('merge', False)
     logging.debug('CAS Provider Login view. Method is %s, merge is %s, template is %s.',
                   request.method, merge, template_name)
@@ -108,13 +128,19 @@ def login(request, template_name='cas/login.html', success_redirect='/account/',
                 logging.debug('Redirecting to %s', success_redirect)
                 return HttpResponseRedirect(success_redirect)
             else:
+                if request.GET.get('warn', False):
+                    return render_to_response(warn_template_name, {
+                        'service': service,
+                        'warn': False
+                    }, context_instance=RequestContext(request))
+                
                 # Create a service ticket and redirect to the service.
-                ticket = create_service_ticket(request.user, service)
+                ticket = ServiceTicket.objects.create(service=service, user=user)
                 if 'service' in request.session:
                     # Don't need this any more.
                     del request.session['service']
 
-                url = _build_service_url(service, ticket.ticket)
+                url = ticket.get_redirect_url()
                 logging.debug('Redirecting to %s', url)
                 return HttpResponseRedirect(url)
 
@@ -123,12 +149,18 @@ def login(request, template_name='cas/login.html', success_redirect='/account/',
 
 
 def validate(request):
+    """Validate ticket via CAS v.1 protocol
+    """
     service = request.GET.get('service', None)
     ticket_string = request.GET.get('ticket', None)
     logger.info('Validating ticket %s for %s', ticket_string, service)
     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
         except ServiceTicket.DoesNotExist:
             logger.exception("Tried to validate with an invalid ticket %s for %s", ticket_string, service)
         except Exception as e:
@@ -146,7 +178,178 @@ def validate(request):
     return HttpResponse("no\n\n")
     
 
-def logout(request, template_name='cas/logout.html'):
+def logout(request, template_name='cas/logout.html',
+           auto_redirect=settings.CAS_AUTO_REDIRECT_AFTER_LOGOUT):
     url = request.GET.get('url', None)
-    auth_logout(request)
-    return render_to_response(template_name, {'url': url}, context_instance=RequestContext(request))
+    if request.user.is_authenticated():
+        for ticket in ServiceTicket.objects.filter(user=request.user):
+            ticket.delete()
+        auth_logout(request)
+        if url and auto_redirect:
+            return HttpResponseRedirect(url)
+    return render_to_response(template_name, {'url': url},
+        context_instance=RequestContext(request))
+
+
+def proxy(request):
+    targetService = request.GET['targetService']
+    pgt_id = request.GET['pgt']
+
+    try:
+        proxyGrantingTicket = ProxyGrantingTicket.objects.get(ticket=pgt_id)
+    except ProxyGrantingTicket.DoesNotExist:
+        return _cas2_error_response(INVALID_TICKET)
+
+    pt = ProxyTicket.objects.create(proxyGrantingTicket=proxyGrantingTicket,
+        user=proxyGrantingTicket.serviceTicket.user,
+        service=targetService)
+    return _cas2_proxy_success(pt.ticket)
+
+
+def ticket_validate(service, ticket_string, pgtUrl):
+    if service is None or ticket_string is None:
+        return _cas2_error_response(INVALID_REQUEST)
+
+    try:
+        if ticket_string.startswith('ST'):
+            ticket = ServiceTicket.objects.get(ticket=ticket_string)
+        elif ticket_string.startswith('PT'):
+            ticket = ProxyTicket.objects.get(ticket=ticket_string)
+        else:
+            return _cas2_error_response(INVALID_TICKET,
+                '%(ticket)s is neither Service (ST-...) nor Proxy Ticket (PT-...)' % {
+                    'ticket': ticket_string})
+    except ServiceTicket.DoesNotExist:
+        return _cas2_error_response(INVALID_TICKET)
+
+    ticketUrl = urlparse.urlparse(ticket.service)
+    serviceUrl = urlparse.urlparse(service)
+
+    if not(ticketUrl.hostname == serviceUrl.hostname and ticketUrl.path == serviceUrl.path and ticketUrl.port == serviceUrl.port):
+        return _cas2_error_response(INVALID_SERVICE)
+
+    pgtIouId = None
+    proxies = ()
+    if pgtUrl is not None:
+        pgt = generate_proxy_granting_ticket(pgtUrl, ticket)
+        if pgt:
+            pgtIouId = pgt.pgtiou
+
+    if hasattr(ticket, 'proxyticket'):
+        pgt = ticket.proxyticket.proxyGrantingTicket
+        # I am issued by this proxy granting ticket
+        if hasattr(pgt.serviceTicket, 'proxyticket'):
+            while pgt:
+                if hasattr(pgt.serviceTicket, 'proxyticket'):
+                    proxies += (pgt.serviceTicket.service,)
+                    pgt = pgt.serviceTicket.proxyticket.proxyGrantingTicket
+                else:
+                    pgt = None
+
+    user = ticket.user
+    return _cas2_sucess_response(user, pgtIouId, proxies)
+
+
+def service_validate(request):
+    """Validate ticket via CAS v.2 protocol"""
+    service = request.GET.get('service', None)
+    ticket_string = request.GET.get('ticket', None)
+    pgtUrl = request.GET.get('pgtUrl', None)
+    if ticket_string.startswith('PT-'):
+        return _cas2_error_response(INVALID_TICKET, "serviceValidate cannot verify proxy tickets")
+    else:
+        return ticket_validate(service, ticket_string, pgtUrl)
+
+
+def proxy_validate(request):
+    """Validate ticket via CAS v.2 protocol"""
+    service = request.GET.get('service', None)
+    ticket_string = request.GET.get('ticket', None)
+    pgtUrl = request.GET.get('pgtUrl', None)
+    return ticket_validate(service, ticket_string, pgtUrl)
+
+
+def generate_proxy_granting_ticket(pgt_url, ticket):
+    proxy_callback_good_status = (200, 202, 301, 302, 304)
+    uri = list(urlparse.urlsplit(pgt_url))
+
+    pgt = ProxyGrantingTicket()
+    pgt.serviceTicket = ticket
+    pgt.targetService = pgt_url
+
+    if hasattr(ticket, 'proxyGrantingTicket'):
+        # here we got a proxy ticket! tata!
+        pgt.pgt = ticket.proxyGrantingTicket
+
+    params = {'pgtId': pgt.ticket, 'pgtIou': pgt.pgtiou}
+
+    query = dict(urlparse.parse_qsl(uri[4]))
+    query.update(params)
+
+    uri[3] = urlencode(query)
+
+    try:
+        response = urllib2.urlopen(urlparse.urlunsplit(uri))
+    except urllib2.HTTPError as e:
+        if not e.code in proxy_callback_good_status:
+            logger.debug('Checking Proxy Callback URL {} returned {}. Not issuing PGT.'.format(uri, e.code))
+            return
+    except urllib2.URLError as e:
+        logger.debug('Checking Proxy Callback URL {} raised URLError. Not issuing PGT.'.format(uri))
+        return
+
+    pgt.save()
+    return pgt
+
+
+def _cas2_proxy_success(pt):
+    return HttpResponse(proxy_success(pt))
+
+
+def _cas2_sucess_response(user, pgt=None, proxies=None):
+    return HttpResponse(auth_success_response(user, pgt, proxies), mimetype='text/xml')
+
+
+def _cas2_error_response(code, message=None):
+    return HttpResponse(u'''<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">
+            <cas:authenticationFailure code="%(code)s">
+                %(message)s
+            </cas:authenticationFailure>
+        </cas:serviceResponse>''' % {
+        'code': code,
+        'message': message if message else dict(ERROR_MESSAGES).get(code)
+    }, mimetype='text/xml')
+
+
+def proxy_success(pt):
+    response = etree.Element(CAS + 'serviceResponse', nsmap=NSMAP)
+    proxySuccess = etree.SubElement(response, CAS + 'proxySuccess')
+    proxyTicket = etree.SubElement(proxySuccess, CAS + 'proxyTicket')
+    proxyTicket.text = pt
+    return unicode(etree.tostring(response, encoding='utf-8'), 'utf-8')
+
+
+def auth_success_response(user, pgt, proxies):
+    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)
+
+    if pgt:
+        pgtElement = etree.SubElement(auth_success, CAS + 'proxyGrantingTicket')
+        pgtElement.text = pgt
+
+    if proxies:
+        proxiesElement = etree.SubElement(auth_success, CAS + "proxies")
+        for proxy in proxies:
+            proxyElement = etree.SubElement(proxiesElement, CAS + "proxy")
+            proxyElement.text = proxy
+
+    return unicode(etree.tostring(response, encoding='utf-8'), 'utf-8')
diff --git a/cas_provider_examples/__init__.py b/cas_provider_examples/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/cas_provider_examples/simple/__init__.py b/cas_provider_examples/simple/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/cas_provider_examples/simple/manage.py b/cas_provider_examples/simple/manage.py
new file mode 100644 (file)
index 0000000..3e4eedc
--- /dev/null
@@ -0,0 +1,14 @@
+#!/usr/bin/env python
+from django.core.management import execute_manager
+import imp
+try:
+    imp.find_module('settings') # Assumed to be in the same directory.
+except ImportError:
+    import sys
+    sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n" % __file__)
+    sys.exit(1)
+
+import settings
+
+if __name__ == "__main__":
+    execute_manager(settings)
diff --git a/cas_provider_examples/simple/settings.py b/cas_provider_examples/simple/settings.py
new file mode 100644 (file)
index 0000000..209927c
--- /dev/null
@@ -0,0 +1,142 @@
+# Django settings for xxx project.
+
+DEBUG = True
+TEMPLATE_DEBUG = DEBUG
+
+ADMINS = (
+    # ('Your Name', 'your_email@example.com'),
+)
+
+MANAGERS = ADMINS
+
+import os
+
+DATABASES = {
+    'default': {
+        'ENGINE': 'django.db.backends.sqlite3',
+        'NAME': os.path.join(os.path.realpath(__file__), 'db'),
+    }
+}
+
+# Local time zone for this installation. Choices can be found here:
+# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
+# although not all choices may be available on all operating systems.
+# On Unix systems, a value of None will cause Django to use the same
+# timezone as the operating system.
+# If running in a Windows environment this must be set to the same as your
+# system time zone.
+TIME_ZONE = 'America/Chicago'
+
+# Language code for this installation. All choices can be found here:
+# http://www.i18nguy.com/unicode/language-identifiers.html
+LANGUAGE_CODE = 'en-us'
+
+SITE_ID = 1
+
+# If you set this to False, Django will make some optimizations so as not
+# to load the internationalization machinery.
+USE_I18N = True
+
+# If you set this to False, Django will not format dates, numbers and
+# calendars according to the current locale
+USE_L10N = True
+
+# Absolute filesystem path to the directory that will hold user-uploaded files.
+# Example: "/home/media/media.lawrence.com/media/"
+MEDIA_ROOT = ''
+
+# URL that handles the media served from MEDIA_ROOT. Make sure to use a
+# trailing slash.
+# Examples: "http://media.lawrence.com/media/", "http://example.com/media/"
+MEDIA_URL = ''
+
+# Absolute path to the directory static files should be collected to.
+# Don't put anything in this directory yourself; store your static files
+# in apps' "static/" subdirectories and in STATICFILES_DIRS.
+# Example: "/home/media/media.lawrence.com/static/"
+STATIC_ROOT = ''
+
+# URL prefix for static files.
+# Example: "http://media.lawrence.com/static/"
+STATIC_URL = '/static/'
+
+# URL prefix for admin static files -- CSS, JavaScript and images.
+# Make sure to use a trailing slash.
+# Examples: "http://foo.com/static/admin/", "/static/admin/".
+ADMIN_MEDIA_PREFIX = '/static/admin/'
+
+# Additional locations of static files
+STATICFILES_DIRS = (
+    # Put strings here, like "/home/html/static" or "C:/www/django/static".
+    # Always use forward slashes, even on Windows.
+    # Don't forget to use absolute paths, not relative paths.
+)
+
+# List of finder classes that know how to find static files in
+# various locations.
+STATICFILES_FINDERS = (
+    'django.contrib.staticfiles.finders.FileSystemFinder',
+    'django.contrib.staticfiles.finders.AppDirectoriesFinder',
+#    'django.contrib.staticfiles.finders.DefaultStorageFinder',
+)
+
+# Make this unique, and don't share it with anybody.
+SECRET_KEY = 'kv*6pmkq47crqskw%wkst!h7xnisy78zzli@rtklgm#y6o=of!'
+
+# List of callables that know how to import templates from various sources.
+TEMPLATE_LOADERS = (
+    'django.template.loaders.filesystem.Loader',
+    'django.template.loaders.app_directories.Loader',
+#     'django.template.loaders.eggs.Loader',
+)
+
+MIDDLEWARE_CLASSES = (
+    'django.middleware.common.CommonMiddleware',
+    'django.contrib.sessions.middleware.SessionMiddleware',
+    'django.middleware.csrf.CsrfViewMiddleware',
+    'django.contrib.auth.middleware.AuthenticationMiddleware',
+    'django.contrib.messages.middleware.MessageMiddleware',
+)
+
+ROOT_URLCONF = 'simple.urls'
+
+import os
+PROJECT_PATH = os.path.abspath(os.path.dirname(__file__))
+
+TEMPLATE_DIRS = (
+     os.path.join(PROJECT_PATH, 'templates')
+)
+
+INSTALLED_APPS = (
+    'django.contrib.auth',
+    'django.contrib.contenttypes',
+    'django.contrib.sessions',
+    'django.contrib.sites',
+    'django.contrib.messages',
+    'django.contrib.staticfiles',
+    'cas_provider',
+    'south',
+)
+
+# A sample logging configuration. The only tangible logging
+# performed by this configuration is to send an email to
+# the site admins on every HTTP 500 error.
+# See http://docs.djangoproject.com/en/dev/topics/logging for
+# more details on how to customize your logging configuration.
+LOGGING = {
+    'version': 1,
+    'disable_existing_loggers': False,
+    'handlers': {
+        'mail_admins': {
+            'level': 'ERROR',
+            'class': 'django.utils.log.AdminEmailHandler'
+        }
+    },
+    'loggers': {
+        'django.request': {
+            'handlers': ['mail_admins'],
+            'level': 'ERROR',
+            'propagate': True,
+        },
+    }
+}
diff --git a/cas_provider_examples/simple/templates/base.html b/cas_provider_examples/simple/templates/base.html
new file mode 100644 (file)
index 0000000..6bd902c
--- /dev/null
@@ -0,0 +1,13 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+    "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
+<head>
+    <title>{% block title %}{% endblock %}</title>
+</head>
+<body>
+    <div id="content">
+        {% block content %}{% endblock %}
+    </div>
+</body>
+
+</html>
\ No newline at end of file
diff --git a/cas_provider_examples/simple/templates/login-success-redirect-target.html b/cas_provider_examples/simple/templates/login-success-redirect-target.html
new file mode 100644 (file)
index 0000000..4ea8caa
--- /dev/null
@@ -0,0 +1 @@
+yeah - success
\ No newline at end of file
diff --git a/cas_provider_examples/simple/urls.py b/cas_provider_examples/simple/urls.py
new file mode 100644 (file)
index 0000000..c717237
--- /dev/null
@@ -0,0 +1,10 @@
+from django.conf.urls.defaults import patterns, include, url
+
+import cas_provider
+from django.views.generic.simple import redirect_to, direct_to_template
+
+urlpatterns = patterns('',
+                       url(r'^', include('cas_provider.urls')),
+                       url(r'^accounts/profile', direct_to_template, {'template': 'login-success-redirect-target.html'}),
+
+                       )
index 8f06deb..fe96615 100644 (file)
--- a/setup.py
+++ b/setup.py
@@ -1,14 +1,26 @@
-
+import os
 from setuptools import setup, find_packages
+
+def read(fname):
+    return open(os.path.join(os.path.dirname(__file__), fname)).read()
+
 setup(
     name='django-cas-provider',
-    version='0.1dev',
+    version='0.3.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/',
+    author='(Chris Williams), Sebastian Annies',
+    author_email='(chris@nitron.org), sebastian.annies@googlemail.com',
+    url='https://github.com/castlabs/django-cas-provider',
     packages=find_packages(),
+    include_package_data=True,
+    license='MIT',
+    long_description=read('README.rst'),
     zip_safe=False,
-    install_requires=['setuptools'],
+    install_requires=['setuptools',
+                      'south>=0.7.2',],
+    classifiers = [
+        "Development Status :: 3 - Alpha",
+        "Framework :: Django",
+        "License :: OSI Approved :: MIT License",
+    ]
 )