From e236585868829946444ac537adbbf481782ecb8f Mon Sep 17 00:00:00 2001 From: =?utf8?q?=C5=81ukasz=20Rekucki?= Date: Sat, 17 Apr 2010 13:28:52 +0200 Subject: [PATCH] Initial commit --- README.rst | 36 ++++ cas.wsgi.template | 24 +++ cas/__init__.py | 0 cas/logging.cfg | 34 ++++ cas/manage.py | 23 +++ cas/settings.py | 107 ++++++++++++ cas/templates/cas/login.html | 21 +++ cas/templates/cas/logout.html | 12 ++ cas/templates/cas_base.html | 13 ++ cas/urls.py | 17 ++ cas/utils.py | 6 + fabfile.py | 159 ++++++++++++++++++ provider/AUTHORS.txt | 2 + provider/LICENSE | 28 +++ provider/MANIFEST.in | 3 + provider/README.rst | 26 +++ provider/cas_provider/__init__.py | 16 ++ provider/cas_provider/admin.py | 11 ++ provider/cas_provider/etree.py | 32 ++++ provider/cas_provider/forms.py | 16 ++ provider/cas_provider/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/cleanuptickets.py | 39 +++++ provider/cas_provider/models.py | 41 +++++ .../cas_provider/templates/cas/login.html | 24 +++ .../cas_provider/templates/cas/logout.html | 12 ++ provider/cas_provider/urls.py | 10 ++ provider/cas_provider/utils.py | 24 +++ provider/cas_provider/views.py | 128 ++++++++++++++ provider/setup.cfg | 3 + provider/setup.py | 14 ++ requirements.txt | 3 + 32 files changed, 884 insertions(+) create mode 100644 README.rst create mode 100644 cas.wsgi.template create mode 100644 cas/__init__.py create mode 100644 cas/logging.cfg create mode 100755 cas/manage.py create mode 100644 cas/settings.py create mode 100644 cas/templates/cas/login.html create mode 100644 cas/templates/cas/logout.html create mode 100644 cas/templates/cas_base.html create mode 100644 cas/urls.py create mode 100644 cas/utils.py create mode 100644 fabfile.py create mode 100644 provider/AUTHORS.txt create mode 100644 provider/LICENSE create mode 100644 provider/MANIFEST.in create mode 100644 provider/README.rst create mode 100644 provider/cas_provider/__init__.py create mode 100644 provider/cas_provider/admin.py create mode 100644 provider/cas_provider/etree.py create mode 100644 provider/cas_provider/forms.py create mode 100644 provider/cas_provider/management/__init__.py create mode 100644 provider/cas_provider/management/commands/__init__.py create mode 100644 provider/cas_provider/management/commands/cleanuptickets.py create mode 100644 provider/cas_provider/models.py create mode 100644 provider/cas_provider/templates/cas/login.html create mode 100644 provider/cas_provider/templates/cas/logout.html create mode 100644 provider/cas_provider/urls.py create mode 100644 provider/cas_provider/utils.py create mode 100644 provider/cas_provider/views.py create mode 100644 provider/setup.cfg create mode 100644 provider/setup.py create mode 100644 requirements.txt diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..bccda5b --- /dev/null +++ b/README.rst @@ -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 `_ w +wersji 1.0. + +Wymagania +========= +* `Django 1.1 `_ +* `zuber/django-cas-provider `_ + +Instalacja i uruchomienie +========================= +1. Ściągnij i zainstaluj `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 `_ na serwerze `Apache2 `_ +przy pomocy załączonego skryptu `dispatch.fcgi`. Inne strategie wdrożeniowe opisane +są w `Dokumentacji Django `_. \ No newline at end of file diff --git a/cas.wsgi.template b/cas.wsgi.template new file mode 100644 index 0000000..2124c1e --- /dev/null +++ b/cas.wsgi.template @@ -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 index 0000000..e69de29 diff --git a/cas/logging.cfg b/cas/logging.cfg new file mode 100644 index 0000000..ce758bc --- /dev/null +++ b/cas/logging.cfg @@ -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 index 0000000..741587d --- /dev/null +++ b/cas/manage.py @@ -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 index 0000000..38343ab --- /dev/null +++ b/cas/settings.py @@ -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 index 0000000..e51cd8d --- /dev/null +++ b/cas/templates/cas/login.html @@ -0,0 +1,21 @@ +{% extends "cas_base.html" %} + +{% block content %} +
+
+ Zaloguj się + {% if errors %} +
    + {% for error in errors %} +
  • {{ error|escape }}
  • + {% endfor %} +
+ {% endif %} + + {{ form.as_table }} +
+

+
+
+{% 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 index 0000000..d2a4009 --- /dev/null +++ b/cas/templates/cas/logout.html @@ -0,0 +1,12 @@ +{% extends "cas_base.html" %} + +{% block title %} +Logged out +{% endblock %} + +{% block content %} +

Logged out

+ +

You have successfully logged out. To ensure that you are logged out of all services, please close your browser.

+ {% if url %}

Click here to return to {{ url }}

{% 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 index 0000000..a49c721 --- /dev/null +++ b/cas/templates/cas_base.html @@ -0,0 +1,13 @@ + + + + + {% block title %}Fundacja Nowoczesna Polska - CAS{% block subtitle %}{% endblock subtitle %}{% endblock title%} + {% block extrahead %} + {% endblock %} + + +
{% block content %} {% endblock %}
+ + diff --git a/cas/urls.py b/cas/urls.py new file mode 100644 index 0000000..f1aeca6 --- /dev/null +++ b/cas/urls.py @@ -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 index 0000000..e5bf2e3 --- /dev/null +++ b/cas/utils.py @@ -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 index 0000000..d70c13f --- /dev/null +++ b/fabfile.py @@ -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 index 0000000..c5710e7 --- /dev/null +++ b/provider/AUTHORS.txt @@ -0,0 +1,2 @@ +Chris Williams +Marek Stepniowski diff --git a/provider/LICENSE b/provider/LICENSE new file mode 100644 index 0000000..ec58004 --- /dev/null +++ b/provider/LICENSE @@ -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 index 0000000..0dc3951 --- /dev/null +++ b/provider/MANIFEST.in @@ -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 index 0000000..d197fbe --- /dev/null +++ b/provider/README.rst @@ -0,0 +1,26 @@ +=================== +django-cas-provider +=================== + +OVERVIEW +========= + +django-cas-provider is a provider for the `Central Authentication Service `_. 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 `_ and `Single Sign-On on Wikipedia `_. + +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 index 0000000..b70697c --- /dev/null +++ b/provider/cas_provider/__init__.py @@ -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 index 0000000..5934487 --- /dev/null +++ b/provider/cas_provider/admin.py @@ -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 index 0000000..3f95751 --- /dev/null +++ b/provider/cas_provider/etree.py @@ -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 index 0000000..ba77b62 --- /dev/null +++ b/provider/cas_provider/forms.py @@ -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 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 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 index 0000000..772fdcb --- /dev/null +++ b/provider/cas_provider/management/commands/cleanuptickets.py @@ -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 index 0000000..5d09912 --- /dev/null +++ b/provider/cas_provider/models.py @@ -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 index 0000000..d61974a --- /dev/null +++ b/provider/cas_provider/templates/cas/login.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% block title %} +Login +{% endblock %} + +{% block content %} +
+
+ Log in to your account + {% if errors %} +
    + {% for error in errors %} +
  • {{ error|escape }}
  • + {% endfor %} +
+ {% endif %} + + {{ form.as_table }} +
+

+
+
+{% endblock %} diff --git a/provider/cas_provider/templates/cas/logout.html b/provider/cas_provider/templates/cas/logout.html new file mode 100644 index 0000000..e5e435f --- /dev/null +++ b/provider/cas_provider/templates/cas/logout.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block title %} +Logged out +{% endblock %} + +{% block content %} +

Logged out

+ +

You have successfully logged out. To ensure that you are logged out of all services, please close your browser.

+ {% if url %}

Click here to return to {{ url }}

{% endif %} +{% endblock %} diff --git a/provider/cas_provider/urls.py b/provider/cas_provider/urls.py new file mode 100644 index 0000000..2bdbe79 --- /dev/null +++ b/provider/cas_provider/urls.py @@ -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 index 0000000..68b791c --- /dev/null +++ b/provider/cas_provider/utils.py @@ -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 index 0000000..2733eb3 --- /dev/null +++ b/provider/cas_provider/views.py @@ -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(''' + + Not all required parameters were sent. + + ''', 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(''' + + The provided ticket is invalid. + + ''', 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 index 0000000..debbf50 --- /dev/null +++ b/provider/setup.cfg @@ -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 index 0000000..489f8d2 --- /dev/null +++ b/provider/setup.py @@ -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 index 0000000..9322f5e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +--find-links=http://stigma.nowoczesnapolska.org.pl/pypi/ + +Django>=1.1.1,<1.2 \ No newline at end of file -- 2.20.1