Initial commit
authorŁukasz Rekucki <lrekucki@gmail.com>
Sat, 17 Apr 2010 11:28:52 +0000 (13:28 +0200)
committerŁukasz Rekucki <lrekucki@gmail.com>
Sat, 17 Apr 2010 11:28:52 +0000 (13:28 +0200)
32 files changed:
README.rst [new file with mode: 0644]
cas.wsgi.template [new file with mode: 0644]
cas/__init__.py [new file with mode: 0644]
cas/logging.cfg [new file with mode: 0644]
cas/manage.py [new file with mode: 0755]
cas/settings.py [new file with mode: 0644]
cas/templates/cas/login.html [new file with mode: 0644]
cas/templates/cas/logout.html [new file with mode: 0644]
cas/templates/cas_base.html [new file with mode: 0644]
cas/urls.py [new file with mode: 0644]
cas/utils.py [new file with mode: 0644]
fabfile.py [new file with mode: 0644]
provider/AUTHORS.txt [new file with mode: 0644]
provider/LICENSE [new file with mode: 0644]
provider/MANIFEST.in [new file with mode: 0644]
provider/README.rst [new file with mode: 0644]
provider/cas_provider/__init__.py [new file with mode: 0644]
provider/cas_provider/admin.py [new file with mode: 0644]
provider/cas_provider/etree.py [new file with mode: 0644]
provider/cas_provider/forms.py [new file with mode: 0644]
provider/cas_provider/management/__init__.py [new file with mode: 0644]
provider/cas_provider/management/commands/__init__.py [new file with mode: 0644]
provider/cas_provider/management/commands/cleanuptickets.py [new file with mode: 0644]
provider/cas_provider/models.py [new file with mode: 0644]
provider/cas_provider/templates/cas/login.html [new file with mode: 0644]
provider/cas_provider/templates/cas/logout.html [new file with mode: 0644]
provider/cas_provider/urls.py [new file with mode: 0644]
provider/cas_provider/utils.py [new file with mode: 0644]
provider/cas_provider/views.py [new file with mode: 0644]
provider/setup.cfg [new file with mode: 0644]
provider/setup.py [new file with mode: 0644]
requirements.txt [new file with mode: 0644]

diff --git a/README.rst b/README.rst
new file mode 100644 (file)
index 0000000..bccda5b
--- /dev/null
@@ -0,0 +1,36 @@
+================================
+Fundacja Nowoczesna Polska - CAS
+================================
+
+O projekcie
+===========
+CAS to aplikacja WWW służąca do autentykacji (a w przyszłości również autoryzacji) użytkowników 
+serwisów Fundacji Nowoczesna Polska. Implementuje on protokół `CAS <http://www.jasig.org/cas>`_ w 
+wersji 1.0.
+
+Wymagania
+=========
+* `Django 1.1 <http://djangoproject.com/>`_
+* `zuber/django-cas-provider <http://github.com/zuber/django-cas-provider>`_
+
+Instalacja i uruchomienie
+=========================
+1. Ściągnij i zainstaluj `pip <http://pypi.python.org/pypi/pip>`_
+2. Przejdź do katalogu aplikacji w konsoli
+3. Zainstaluj wymagane biblioteki (patrz sekcja wymagania_) komendą::
+
+       pip install -r requirements.txt
+
+4. Wypełnij bazę danych (Django poprosi o utworzenie pierwszego użytkownika)::
+
+       ./manage.py syncdb
+       
+5. Uruchom serwer deweloperski::
+
+       ./manage.py runserver
+
+6. Przy wdrożeniu będziesz musiał najpewniej utworzyć plik `localsettings.py` i wpisać tam 
+ustawienia używanej bazy danych. Zalecane jest serwowanie aplikacji 
+przez `modwsgi <http://code.google.com/p/modwsgi/>`_ na serwerze `Apache2 <http://httpd.apache.org/>`_ 
+przy pomocy załączonego skryptu `dispatch.fcgi`. Inne strategie wdrożeniowe opisane 
+są w `Dokumentacji Django <http://docs.djangoproject.com/en/dev/howto/deployment/#howto-deployment-index>`_.
\ No newline at end of file
diff --git a/cas.wsgi.template b/cas.wsgi.template
new file mode 100644 (file)
index 0000000..2124c1e
--- /dev/null
@@ -0,0 +1,24 @@
+#!%(python)s
+import site
+site.addsitedir('%(path)s/lib/python2.5/site-packages')
+
+import os
+from os.path import abspath, dirname, join
+import sys
+
+# Redirect sys.stdout to sys.stderr for bad libraries like geopy that use
+# print statements for optional import exceptions.
+sys.stdout = sys.stderr
+
+# Add apps and lib directories to PYTHONPATH
+sys.path = [
+       '%(path)s/releases/current/%(project_name)s',
+       '%(path)s/releases/current/provider',
+       '%(path)s/releases/current',
+] + sys.path
+
+# Run Django
+os.environ['DJANGO_SETTINGS_MODULE'] = 'settings'
+
+from django.core.handlers.wsgi import WSGIHandler
+application = WSGIHandler()
diff --git a/cas/__init__.py b/cas/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/cas/logging.cfg b/cas/logging.cfg
new file mode 100644 (file)
index 0000000..ce758bc
--- /dev/null
@@ -0,0 +1,34 @@
+[loggers]
+keys=root,fnp
+
+[handlers]
+keys=console,lf
+
+[formatters]
+keys=default
+
+[logger_root]
+level=DEBUG
+handlers=console
+
+[logger_fnp]
+level=DEBUG
+handlers=lf,console
+qualname=fnp
+propagate=0
+
+[formatter_default]
+format=%(asctime)s %(name)s/%(levelname)s :: %(module)s:%(lineno)d :: %(message)s
+datefmt=
+
+[handler_console]
+class=StreamHandler
+level=DEBUG
+formatter=default
+args=(sys.stderr, )
+
+[handler_lf]
+class=FileHandler
+level=DEBUG
+formatter=default
+args=("/var/services/logs/cas.log",) 
\ No newline at end of file
diff --git a/cas/manage.py b/cas/manage.py
new file mode 100755 (executable)
index 0000000..741587d
--- /dev/null
@@ -0,0 +1,23 @@
+#!/usr/bin/env python
+from django.core.management import execute_manager
+
+from os import path
+import sys
+
+PROJECT_ROOT = path.realpath(path.dirname(__file__))  
+sys.path.insert(0, path.abspath(path.join(PROJECT_ROOT, "..", "provider")))                                 
+print sys.path
+
+try:
+    import settings # Assumed to be in the same directory.
+except ImportError:
+    import traceback
+    traceback.print_exc(file =sys.stderr)
+    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(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__)
+    sys.exit(1)
+
+if __name__ == "__main__":
+    # Append lib and apps directories to PYTHONPATH
+    
+    
+    execute_manager(settings)
diff --git a/cas/settings.py b/cas/settings.py
new file mode 100644 (file)
index 0000000..38343ab
--- /dev/null
@@ -0,0 +1,107 @@
+# -*- coding: utf-8 -*-
+from os import path
+
+PROJECT_ROOT = path.realpath(path.dirname(__file__))
+
+DEBUG = True
+TEMPLATE_DEBUG = DEBUG
+
+ADMINS = []
+
+MANAGERS = ADMINS
+
+DATABASE_ENGINE = 'sqlite3'           # 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'.
+DATABASE_NAME = PROJECT_ROOT + '/dev.sqlite'             # Or path to database file if using sqlite3.
+DATABASE_USER = ''             # Not used with sqlite3.
+DATABASE_PASSWORD = ''         # Not used with sqlite3.
+DATABASE_HOST = ''             # Set to empty string for localhost. Not used with sqlite3.
+DATABASE_PORT = ''             # Set to empty string for default. Not used with sqlite3.
+
+# 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.
+# If running in a Windows environment this must be set to the same as your
+# system time zone.
+TIME_ZONE = 'Europe/Warsaw'
+
+# Language code for this installation. All choices can be found here:
+# http://www.i18nguy.com/unicode/language-identifiers.html
+LANGUAGE_CODE = 'pl'
+
+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
+
+# Absolute path to the directory that holds media.
+# Example: "/home/media/media.lawrence.com/"
+MEDIA_ROOT = PROJECT_ROOT + '/media/'
+STATIC_ROOT = PROJECT_ROOT + '/static/'
+
+# URL that handles the media served from MEDIA_ROOT. Make sure to use a
+# trailing slash if there is a path component (optional in other cases).
+# Examples: "http://media.lawrence.com", "http://example.com/media/"
+MEDIA_URL = '/media/'
+STATIC_URL = '/static/'
+
+# URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a
+# trailing slash.
+# Examples: "http://foo.com/media/", "/media/".
+ADMIN_MEDIA_PREFIX = '/admin-media/'
+
+# List of callables that know how to import templates from various sources.
+TEMPLATE_LOADERS = (
+    'django.template.loaders.filesystem.load_template_source',
+    'django.template.loaders.app_directories.load_template_source',
+#     'django.template.loaders.eggs.load_template_source',
+)
+
+TEMPLATE_CONTEXT_PROCESSORS = (
+    "django.core.context_processors.auth",
+    "django.core.context_processors.debug",
+    "django.core.context_processors.i18n",
+    "django.core.context_processors.request",
+)
+
+
+MIDDLEWARE_CLASSES = (
+    'django.middleware.common.CommonMiddleware',
+    'django.contrib.sessions.middleware.SessionMiddleware',
+    'django.contrib.auth.middleware.AuthenticationMiddleware',
+    'django.middleware.doc.XViewMiddleware',
+)
+
+ROOT_URLCONF = 'urls'
+
+TEMPLATE_DIRS = (
+    PROJECT_ROOT + '/templates',    
+)
+
+INSTALLED_APPS = (
+    'django.contrib.auth',
+    'django.contrib.contenttypes',
+    'django.contrib.sessions',
+    'django.contrib.sites',
+    'django.contrib.admin',
+    'django.contrib.admindocs',
+    
+    'cas_provider',
+)
+
+# django-cas-provider settings
+LOGIN_URL = '/cas/login/'
+LOGOUT_URL = '/cas/logout/'
+CAS_CUSTOM_ATTRIBUTES_CALLBACK = 'utils.custom_attributes_callback'
+SESSION_COOKIE_NAME = 'fnpcas_sessionid'
+
+# Python logging settings
+import logging
+import logging.config
+logging.config.fileConfig(path.join(PROJECT_ROOT, "logging.cfg"))
+
+# Import localsettings file, which may override settings defined here
+try:
+    from localsettings import *
+except ImportError:
+    pass
diff --git a/cas/templates/cas/login.html b/cas/templates/cas/login.html
new file mode 100644 (file)
index 0000000..e51cd8d
--- /dev/null
@@ -0,0 +1,21 @@
+{% extends "cas_base.html" %}
+
+{% block content %}
+  <form action='.' method='post'>
+    <fieldset>
+      <legend>Zaloguj się</legend>
+      {% if errors %}
+        <ul>
+            {% for error in errors %}
+                <li>{{ error|escape }}</li>
+            {% endfor %}
+        </ul>
+      {% endif %}
+      <table style="border: none;">
+        {{ form.as_table }}
+      </table>
+      <p><input type="submit" value="Login"/></p>
+    </fieldset>
+  </form>
+{% endblock %}
\ No newline at end of file
diff --git a/cas/templates/cas/logout.html b/cas/templates/cas/logout.html
new file mode 100644 (file)
index 0000000..d2a4009
--- /dev/null
@@ -0,0 +1,12 @@
+{% extends "cas_base.html" %}
+{% block title %}
+Logged out
+{% endblock %}
+{% block content %}
+    <h3>Logged out</h3>
+    <p>You have successfully logged out. To ensure that you are logged out of all services, please close your browser.</p>
+    {% if url %}<p><a href="{{ url }}">Click here</a> to return to {{ url }}</p>{% endif %}
+{% endblock %}
\ No newline at end of file
diff --git a/cas/templates/cas_base.html b/cas/templates/cas_base.html
new file mode 100644 (file)
index 0000000..a49c721
--- /dev/null
@@ -0,0 +1,13 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
+    "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+    <head>
+        <meta http-equiv="Content-type" content="text/html; charset=utf-8" />
+        <title>{% block title %}Fundacja Nowoczesna Polska - CAS{% block subtitle %}{% endblock subtitle %}{% endblock title%}</title>
+        {% block extrahead %}
+        {% endblock %}
+    </head>
+    <body id="{% block bodyid %}base{% endblock %}">
+    <div id="content">{% block content %} {% endblock %}</div>
+    </body>
+</html>
diff --git a/cas/urls.py b/cas/urls.py
new file mode 100644 (file)
index 0000000..f1aeca6
--- /dev/null
@@ -0,0 +1,17 @@
+# -*- coding: utf-8 -*-
+from django.conf.urls.defaults import *
+from django.contrib import admin
+from django.conf import settings
+
+admin.autodiscover()
+
+urlpatterns = patterns('',
+    # Admin panel
+    url(r'^admin/doc/', include('django.contrib.admindocs.urls')),
+    url(r'^admin/(.*)', admin.site.root),
+
+    # django-cas-provider
+    url(r'^', include('cas_provider.urls')),
+)
+
+
diff --git a/cas/utils.py b/cas/utils.py
new file mode 100644 (file)
index 0000000..e5bf2e3
--- /dev/null
@@ -0,0 +1,6 @@
+def custom_attributes_callback(user):
+    return {
+        'email': user.email,
+        'firstname': user.first_name,
+        'lastname': user.last_name,
+    }
diff --git a/fabfile.py b/fabfile.py
new file mode 100644 (file)
index 0000000..d70c13f
--- /dev/null
@@ -0,0 +1,159 @@
+from __future__ import with_statement # needed for python 2.5
+from fabric.api import *
+from fabric.contrib import files
+
+import os
+
+
+# ==========
+# = Config =
+# ==========
+# Globals
+env.project_name = 'cas'
+env.use_south = False
+
+# Servers
+def staging():
+    """Use staging server"""
+    env.hosts = ['stigma.nowoczesnapolska.org.pl:2222']
+    env.user = 'platforma'
+    env.path = '/var/services/cas'
+    env.python = '/usr/bin/python'
+    env.virtualenv = '/usr/bin/virtualenv'
+    env.pip = '/usr/bin/pip'
+    
+def production():
+    """Use production server"""
+    env.hosts = ['wolnelektury.pl:22123']
+    env.user = 'fundacja'
+    env.path = '/opt/lektury/cas'
+    env.python = '/opt/lektury/basevirtualenv/bin/python'
+    env.virtualenv = '/opt/lektury/basevirtualenv/bin/virtualenv'
+    env.pip = '/opt/lektury/basevirtualenv/bin/pip'
+
+
+# =========
+# = Tasks =
+# =========
+def test():
+    "Run the test suite and bail out if it fails"
+    require('hosts', 'path', provided_by=[staging, production])
+    result = run('cd %(path)s/%(project_name)s; %(python)s manage.py test' % env)
+
+def setup():
+    """
+    Setup a fresh virtualenv as well as a few useful directories, then run
+    a full deployment. virtualenv and pip should be already installed.
+    """
+    require('hosts', 'path', provided_by=[staging, production])
+
+    run('mkdir -p %(path)s; cd %(path)s; %(virtualenv)s --no-site-packages .;' % env, pty=True)
+    run('cd %(path)s; mkdir releases; mkdir shared; mkdir packages;' % env, pty=True)
+    run('cd %(path)s/releases; ln -s . current; ln -s . previous' % env, pty=True)
+    deploy()
+
+def deploy():
+    """
+    Deploy the latest version of the site to the servers, 
+    install any required third party modules, 
+    install the virtual host and then restart the webserver
+    """
+    require('hosts', 'path', provided_by=[staging, production])
+
+    import time
+    env.release = time.strftime('%Y-%m-%dT%H%M')
+
+    upload_tar_from_git()
+    upload_wsgi_script()
+    # upload_vhost_sample()
+    install_requirements()
+    copy_localsettings()
+    symlink_current_release()
+    migrate()
+    restart_webserver()
+
+def deploy_version(version):
+    "Specify a specific version to be made live"
+    require('hosts', 'path', provided_by=[localhost,webserver])
+    env.version = version
+    with cd(env.path):
+        run('rm releases/previous; mv releases/current releases/previous;', pty=True)
+        run('ln -s %(version)s releases/current' % env, pty=True)
+    restart_webserver()
+
+def rollback():
+    """
+    Limited rollback capability. Simple loads the previously current
+    version of the code. Rolling back again will swap between the two.
+    """
+    require('hosts', provided_by=[staging, production])
+    require('path')
+    with cd(env.path):
+        run('mv releases/current releases/_previous;', pty=True)
+        run('mv releases/previous releases/current;', pty=True)
+        run('mv releases/_previous releases/previous;', pty=True)
+    restart_webserver()
+
+
+# =====================================================================
+# = Helpers. These are called by other functions rather than directly =
+# =====================================================================
+def upload_tar_from_git():
+    "Create an archive from the current Git master branch and upload it"
+    print '>>> upload tar from git'
+    require('release', provided_by=[deploy])
+    local('git archive --format=tar master | gzip > %(release)s.tar.gz' % env)
+    run('mkdir -p %(path)s/releases/%(release)s' % env, pty=True)
+    run('mkdir -p %(path)s/packages' % env, pty=True)
+    put('%(release)s.tar.gz' % env, '%(path)s/packages/' % env)
+    run('cd %(path)s/releases/%(release)s && tar zxf ../../packages/%(release)s.tar.gz' % env, pty=True)
+    local('rm %(release)s.tar.gz' % env)
+
+def upload_vhost_sample():
+    "Create and upload Apache virtual host configuration sample"
+    print ">>> upload vhost sample"
+    files.upload_template('%(project_name)s.vhost.template' % env, '%(path)s/%(project_name)s.vhost.sample' % env, context=env)
+
+def upload_wsgi_script():
+    "Create and upload a wsgi script sample"
+    print ">>> upload wsgi script sample"
+    files.upload_template('%(project_name)s.wsgi.template' % env, '%(path)s/%(project_name)s.wsgi' % env, context=env)
+    run('chmod ug+x %(path)s/%(project_name)s.wsgi' % env)
+
+def install_requirements():
+    "Install the required packages from the requirements file using pip"
+    print '>>> install requirements'
+    require('release', provided_by=[deploy])
+    run('cd %(path)s; %(pip)s install -E . -r %(path)s/releases/%(release)s/requirements.txt' % env, pty=True)
+
+def copy_localsettings():
+    "Copy localsettings.py from root directory to release directory (if this file exists)"
+    print ">>> copy localsettings"
+    require('release', provided_by=[deploy])
+    require('path', provided_by=[staging, production])
+
+    with settings(warn_only=True):
+        run('cp %(path)s/localsettings.py %(path)s/releases/%(release)s/%(project_name)s' % env)
+
+def symlink_current_release():
+    "Symlink our current release"
+    print '>>> symlink current release'
+    require('release', provided_by=[deploy])
+    require('path', provided_by=[staging, production])
+    with cd(env.path):
+        run('rm releases/previous; mv releases/current releases/previous')
+        run('ln -s %(release)s releases/current' % env)
+
+def migrate():
+    "Update the database"
+    print '>>> migrate'
+    require('project_name', provided_by=[staging, production])
+    with cd('%(path)s/releases/current/%(project_name)s' % env):
+        run('../../../bin/python manage.py syncdb --noinput' % env, pty=True)
+        if env.use_south:
+            run('../../../bin/python manage.py migrate' % env, pty=True)
+
+def restart_webserver():
+    "Restart the web server"
+    print '>>> restart webserver'
+    run('touch %(path)s/releases/current/%(project_name)s/%(project_name)s.wsgi' % env)
diff --git a/provider/AUTHORS.txt b/provider/AUTHORS.txt
new file mode 100644 (file)
index 0000000..c5710e7
--- /dev/null
@@ -0,0 +1,2 @@
+Chris Williams <chris@nitron.org>
+Marek Stepniowski <marek@stepniowski.com>
diff --git a/provider/LICENSE b/provider/LICENSE
new file mode 100644 (file)
index 0000000..ec58004
--- /dev/null
@@ -0,0 +1,28 @@
+Copyright (c) 2009, Chris Williams
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+    * Neither the name of the author nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/provider/MANIFEST.in b/provider/MANIFEST.in
new file mode 100644 (file)
index 0000000..0dc3951
--- /dev/null
@@ -0,0 +1,3 @@
+recursive-include cas_provider/templates *.html
+include README.rst
+include LICENSE
\ No newline at end of file
diff --git a/provider/README.rst b/provider/README.rst
new file mode 100644 (file)
index 0000000..d197fbe
--- /dev/null
@@ -0,0 +1,26 @@
+===================
+django-cas-provider
+===================
+
+OVERVIEW
+=========
+
+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::
+
+       python setup.py install
+
+Or, put `cas_provider` somewhere on your Python path.
+       
+USAGE
+======
+
+#. Add ``'cas_provider'`` to your ``INSTALLED_APPS`` tuple in *settings.py*.
+#. 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)
diff --git a/provider/cas_provider/__init__.py b/provider/cas_provider/__init__.py
new file mode 100644 (file)
index 0000000..b70697c
--- /dev/null
@@ -0,0 +1,16 @@
+from django.conf import settings
+
+__all__ = []
+
+_DEFAULTS = {
+    'CAS_TICKET_EXPIRATION': 5, # In minutes
+    'CAS_CUSTOM_ATTRIBUTES_CALLBACK': None,
+}
+
+for key, value in _DEFAULTS.iteritems():
+    try:
+        getattr(settings, key)
+    except AttributeError:
+        setattr(settings, key, value)
+    except ImportError:
+        pass
\ No newline at end of file
diff --git a/provider/cas_provider/admin.py b/provider/cas_provider/admin.py
new file mode 100644 (file)
index 0000000..5934487
--- /dev/null
@@ -0,0 +1,11 @@
+from django.contrib import admin
+
+from cas_provider.models import ServiceTicket, LoginTicket
+
+class ServiceTicketAdmin(admin.ModelAdmin):
+    pass
+admin.site.register(ServiceTicket, ServiceTicketAdmin)
+
+class LoginTicketAdmin(admin.ModelAdmin):
+    pass
+admin.site.register(LoginTicket, LoginTicketAdmin)
\ No newline at end of file
diff --git a/provider/cas_provider/etree.py b/provider/cas_provider/etree.py
new file mode 100644 (file)
index 0000000..3f95751
--- /dev/null
@@ -0,0 +1,32 @@
+# Import etree from anywhere
+try:
+    # lxml http://codespeak.net/lxml/
+    from lxml import etree
+    
+    # Define register_namespace function and ElementRoot for proper serialization
+    NSMAP = {}
+    def register_namespace(prefix, uri):
+        NSMAP[prefix] = uri
+    
+    def ElementRoot(*args, **kwargs):
+        kwargs['nsmap'] = NSMAP
+        return etree.Element(*args, **kwargs)
+
+except ImportError:
+    try:
+        # normal cElementTree install
+        import cElementTree as etree
+    except ImportError:
+        # normal ElementTree install
+        import elementtree.ElementTree as etree
+
+    try:
+        register_namespace = etree.register_namespace
+    except AttributeError:
+        def register_namespace(prefix, uri):
+            etree._namespace_map[uri] = prefix
+
+    def ElementRoot(*args, **kwargs):
+        return etree.Element(*args, **kwargs)
+
+__all__ = ('etree', 'register_namespace', 'ElementRoot')
diff --git a/provider/cas_provider/forms.py b/provider/cas_provider/forms.py
new file mode 100644 (file)
index 0000000..ba77b62
--- /dev/null
@@ -0,0 +1,16 @@
+from django import forms
+from django.contrib.auth.forms import AuthenticationForm
+from django.contrib.auth import authenticate
+
+from cas_provider.utils import create_login_ticket
+
+class LoginForm(forms.Form):
+    username = forms.CharField(max_length=30)
+    password = forms.CharField(widget=forms.PasswordInput)
+    #warn = forms.BooleanField(required=False)  # TODO: Implement
+    lt = forms.CharField(widget=forms.HiddenInput, initial=create_login_ticket)
+    def __init__(self, service=None, renew=None, gateway=None, request=None, *args, **kwargs):
+        super(LoginForm, self).__init__(*args, **kwargs)
+        self.request = request
+        if service is not None:
+            self.fields['service'] = forms.CharField(widget=forms.HiddenInput, initial=service)
\ No newline at end of file
diff --git a/provider/cas_provider/management/__init__.py b/provider/cas_provider/management/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/provider/cas_provider/management/commands/__init__.py b/provider/cas_provider/management/commands/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/provider/cas_provider/management/commands/cleanuptickets.py b/provider/cas_provider/management/commands/cleanuptickets.py
new file mode 100644 (file)
index 0000000..772fdcb
--- /dev/null
@@ -0,0 +1,39 @@
+"""
+A management command which deletes expired service tickets (e.g.,
+from the database.
+
+Calls ``ServiceTickets.objects.delete_expired_users()``, which
+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
+
+from cas_provider.models import ServiceTicket, LoginTicket
+
+class Command(NoArgsCommand):
+    help = "Delete expired service tickets from the database"
+
+    def handle_noargs(self, **options):
+        print "Service tickets:"
+        tickets = ServiceTicket.objects.all()
+        for ticket in tickets:
+            expiration = datetime.timedelta(minutes=settings.CAS_TICKET_EXPIRATION)
+            if datetime.datetime.now() > ticket.created + expiration:
+                print "Deleting %s..." % ticket.ticket
+                ticket.delete()
+            else:
+                print "%s not expired..." % ticket.ticket
+        tickets = LoginTicket.objects.all()
+        print "Login tickets:"
+        for ticket in tickets:
+            expiration = datetime.timedelta(minutes=settings.CAS_TICKET_EXPIRATION)
+            if datetime.datetime.now() > ticket.created + expiration:
+                print "Deleting %s..." % ticket.ticket
+                ticket.delete()
+            else:
+                print "%s not expired..." % ticket.ticket
\ No newline at end of file
diff --git a/provider/cas_provider/models.py b/provider/cas_provider/models.py
new file mode 100644 (file)
index 0000000..5d09912
--- /dev/null
@@ -0,0 +1,41 @@
+from django.db import models
+from django.contrib.auth.models import User
+from django.conf import settings
+from django.core.urlresolvers import get_callable
+
+from cas_provider.etree import etree, register_namespace, ElementRoot
+
+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)
+        
+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)
+
+CAS_URI = 'http://www.yale.edu/tp/cas'
+register_namespace('cas', CAS_URI)
+CAS = '{%s}' % CAS_URI
+
+def auth_success_response(user):
+    attrs = {}
+    if settings.CAS_CUSTOM_ATTRIBUTES_CALLBACK:
+        callback = get_callable(settings.CAS_CUSTOM_ATTRIBUTES_CALLBACK)
+        attrs = callback(user)
+    
+    response = ElementRoot(CAS + 'serviceResponse')
+    auth_success = etree.SubElement(response, CAS + 'authenticationSuccess')
+    username = etree.SubElement(auth_success, CAS + 'user')
+    username.text = user.username
+    for name, value in attrs.items():
+        element = etree.SubElement(auth_success, name)
+        element.text = value
+    return unicode(etree.tostring(response, encoding='utf-8'), 'utf-8')
diff --git a/provider/cas_provider/templates/cas/login.html b/provider/cas_provider/templates/cas/login.html
new file mode 100644 (file)
index 0000000..d61974a
--- /dev/null
@@ -0,0 +1,24 @@
+{% extends "base.html" %}
+
+{% block title %}
+Login
+{% endblock %}
+
+{% block content %}
+  <form action='.' method='post'>
+    <fieldset>
+      <legend>Log in to your account</legend>
+      {% if errors %}
+        <ul>
+            {% for error in errors %}
+                <li>{{ error|escape }}</li>
+            {% endfor %}
+        </ul>
+      {% endif %}
+      <table style="border: none;">
+        {{ form.as_table }}
+      </table>
+      <p><input type="submit" value="Login"/></p>
+    </fieldset>
+  </form>
+{% endblock %}
diff --git a/provider/cas_provider/templates/cas/logout.html b/provider/cas_provider/templates/cas/logout.html
new file mode 100644 (file)
index 0000000..e5e435f
--- /dev/null
@@ -0,0 +1,12 @@
+{% extends "base.html" %}
+
+{% block title %}
+Logged out
+{% endblock %}
+
+{% block content %}
+    <h3>Logged out</h3>
+
+    <p>You have successfully logged out. To ensure that you are logged out of all services, please close your browser.</p>
+    {% if url %}<p><a href="{{ url }}">Click here</a> to return to {{ url }}</p>{% endif %}
+{% endblock %}
diff --git a/provider/cas_provider/urls.py b/provider/cas_provider/urls.py
new file mode 100644 (file)
index 0000000..2bdbe79
--- /dev/null
@@ -0,0 +1,10 @@
+from django.conf.urls.defaults import *
+
+from cas_provider.views import *
+
+urlpatterns = patterns('',
+    url(r'^login/$', login),
+    url(r'^validate/$', validate),
+    url(r'^serviceValidate/$', service_validate),
+    url(r'^logout/$', logout),
+)
\ No newline at end of file
diff --git a/provider/cas_provider/utils.py b/provider/cas_provider/utils.py
new file mode 100644 (file)
index 0000000..68b791c
--- /dev/null
@@ -0,0 +1,24 @@
+from random import Random
+import string
+
+from cas_provider.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
diff --git a/provider/cas_provider/views.py b/provider/cas_provider/views.py
new file mode 100644 (file)
index 0000000..2733eb3
--- /dev/null
@@ -0,0 +1,128 @@
+from django.http import HttpResponse, HttpResponseForbidden, 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 cas_provider.forms import LoginForm
+from cas_provider.models import ServiceTicket, LoginTicket, auth_success_response
+from cas_provider.utils import create_service_ticket
+
+import urlparse, urllib
+
+try:
+    from urlparse import parse_qs as url_parse_qs
+except ImportError:
+    from cgi import parse_qs as url_parse_qs
+     
+
+import logging
+logger = logging.getLogger("fnp.cas.provider")
+
+__all__ = ['login', 'validate', 'service_validate', 'logout']
+
+def _add_query_param(url, param, value):    
+    parsed = urlparse.urlparse(url)
+    query = url_parse_qs(parsed.query)
+    query[param] = [unicode(value, 'utf-8')]
+    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()
+
+
+def login(request, template_name = 'cas/login.html', success_redirect = '/accounts/'):
+    service = request.GET.get('service', None)
+    
+    if request.user.is_authenticated():
+        logger.info("User %s passed auth, service is %s", request.user, service)
+        
+        if service is not None:
+            ticket = create_service_ticket(request.user, service)
+            target = _add_query_param(service, 'ticket', ticket.ticket)
+            logger.info("Redirecting to %s", target)
+            return HttpResponseRedirect(target)
+        else:
+            logger.info("Redirecting to default: %s", success_redirect)
+            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)
+        
+        logger.debug("User %s logging in", username)
+        logger.info("Login submit: serivce = %s, Lticket=%s",service, lt)                      
+
+        try:
+            login_ticket = LoginTicket.objects.get(ticket = lt)
+        except:
+            errors.append('Login ticket expired. Please try again.')
+        else:
+            login_ticket.delete()
+            logger.debug("Auth")
+            user = authenticate(username = username, password = password)
+            if user is not None:
+                if user.is_active:
+                    logger.debug("AuthLogin")
+                    auth_login(request, user)
+                    if service is not None:                        
+                        ticket = create_service_ticket(user, service)
+                        logger.info("Service=%s, ticket=%s", service, ticket)
+                        target = _add_query_param(service, 'ticket', ticket.ticket)
+                        logger.info("Redirecting to %s", target)
+                        return HttpResponseRedirect(target)
+                    else:
+                        logger.info("Redirecting to default: %s", success_redirect)
+                        return HttpResponseRedirect(success_redirect)
+                else:
+                    errors.append('This account is disabled.')
+            else:
+                    errors.append('Incorrect username and/or password.')
+    
+    logger.debug("LOGIN GET, service = %s", service)
+    form = LoginForm(service)
+    return render_to_response(template_name, {'form': form, 'errors': errors}, context_instance = RequestContext(request))
+
+def validate(request):
+    service = request.GET.get('service', None)
+    ticket_string = request.GET.get('ticket', None)
+    if service is not None and ticket_string is not None:
+        try:
+            ticket = ServiceTicket.objects.get(ticket = ticket_string)
+            username = ticket.user.username
+            ticket.delete()
+            return HttpResponse("yes\n%s\n" % username)
+        except:
+            pass
+    return HttpResponse("no\n\n")
+
+def service_validate(request):
+    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')
+
+    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.
+            </cas:authenticationFailure>
+        </cas:serviceResponse>''', mimetype = 'text/xml')
+
+def logout(request, template_name = 'cas/logout.html'):
+    url = request.GET.get('url', None)
+    auth_logout(request)
+    return render_to_response(template_name, {'url': url}, context_instance = RequestContext(request))
diff --git a/provider/setup.cfg b/provider/setup.cfg
new file mode 100644 (file)
index 0000000..debbf50
--- /dev/null
@@ -0,0 +1,3 @@
+[egg_info]
+tag_build = .dev
+tag_date = 1
\ No newline at end of file
diff --git a/provider/setup.py b/provider/setup.py
new file mode 100644 (file)
index 0000000..489f8d2
--- /dev/null
@@ -0,0 +1,14 @@
+from setuptools import setup, find_packages
+setup(
+    name='django-cas-provider',
+    version='0.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'],
+)
diff --git a/requirements.txt b/requirements.txt
new file mode 100644 (file)
index 0000000..9322f5e
--- /dev/null
@@ -0,0 +1,3 @@
+--find-links=http://stigma.nowoczesnapolska.org.pl/pypi/
+
+Django>=1.1.1,<1.2
\ No newline at end of file