diff --git a/README.txt b/README.txt index aca641a..292d70e 100644 --- a/README.txt +++ b/README.txt @@ -9,7 +9,12 @@ be automatically loaded by the plugin framework. Settings ======== -*TODO* +Name Description +================= ========================= -A2_IDP_CAS_PROVIDER -A2_IDP_CAS_TICKET_EXPIRATION +A2_IDP_CAS_SERVICES A sequence of URL prefixes, any URL starting with this + prefix is authorized to request a ticket + +A2_IDP_CAS_PROVIDER Class implementating CAS views, default to + `authentic2_idp_cas.views.CasProvider` +A2_IDP_CAS_TICKET_EXPIRATION Ticket lifetime diff --git a/authentic2_idp_cas/__init__.py b/authentic2_idp_cas/__init__.py index 575a7f0..c93259f 100644 --- a/authentic2_idp_cas/__init__.py +++ b/authentic2_idp_cas/__init__.py @@ -1,6 +1,7 @@ -from django.utils.timezone import now from django.template.loader import render_to_string +from . import utils + __version__ = '1.0' class Plugin(object): @@ -12,22 +13,15 @@ class Plugin(object): return [__name__] def logout_list(self, request): - from . import models - - qs = models.CasService.objects.filter(accesstoken__user=request.user, - accesstoken__expires__gt=now(), logout_url__isnull=False) \ - .distinct() - - l = [] - for client in qs: - name = client.name - url = client.get_logout_url() + fragments = [] + for name, logout in utils.get_logout_urls(request): + url = logout.get_logout_url() ctx = { - 'needs_iframe': client.logout_use_iframe, + 'needs_iframe': logout.logout_use_iframe, 'name': name, 'url': url, - 'iframe_timeout': client.logout_use_iframe_timeout, + 'iframe_timeout': logout.logout_use_iframe_timeout, } - content = render_to_string('idp/saml/logout_fragment.html', ctx) - l.append(content) - return l + content = render_to_string('authentic2_idp_cas/logout_fragment.html', ctx) + fragments.append(content) + return fragments diff --git a/authentic2_idp_cas/app_settings.py b/authentic2_idp_cas/app_settings.py index 4560fd4..5bddd8c 100644 --- a/authentic2_idp_cas/app_settings.py +++ b/authentic2_idp_cas/app_settings.py @@ -1,28 +1,35 @@ -from django.utils.importlib import import_module - class AppSettings(object): + __DEFAULTS = { + 'SERVICES': (), + } def __init__(self, prefix): self.prefix = prefix @property def PROVIDER(self): + from django.utils.importlib import import_module cas_provider = self._setting('CAS_PROVIDER', 'authentic2_idp_cas.views.Authentic2CasProvider') module, cls = cas_provider.rsplit('.', 1) module = import_module(module) return getattr(module, cls) + @property def TICKET_EXPIRATION(self): return self._setting('TICKET_EXPIRATION', 240) - def _setting(self, name, dflt): from django.conf import settings return getattr(settings, self.prefix + name, dflt) + def __getattr__(self, name): + if name not in self.__DEFAULTS: + raise AttributeError(name) + return self._setting(name, self.__DEFAULTS[name]) + # Ugly? Guido recommends this himself ... # http://mail.python.org/pipermail/python-ideas/2012-May/014969.html diff --git a/authentic2_idp_cas/managers.py b/authentic2_idp_cas/managers.py index 106819d..ee7dea4 100644 --- a/authentic2_idp_cas/managers.py +++ b/authentic2_idp_cas/managers.py @@ -19,4 +19,15 @@ class CasTicketQuerySet(query.QuerySet): qs = self.filter(creation__lt=now()-delta) qs.delete() + +class CasServiceQuerySet(query.QuerySet): + def for_domain(self, domain): + q = query.Q(domain=domain) + parts = domain.split('.') + for i in range(1, len(parts)): + q |= query.Q(domain='.%s' % '.'.join(parts[i:])) + return self.filter(q).order_by('-domain') + +CasServiceManager = managers.PassThroughManager.for_queryset_class(CasServiceQuerySet) + CasTicketManager = managers.PassThroughManager.for_queryset_class(CasTicketQuerySet) diff --git a/authentic2_idp_cas/migrations/0001_initial.py b/authentic2_idp_cas/migrations/0001_initial.py new file mode 100644 index 0000000..25b876d --- /dev/null +++ b/authentic2_idp_cas/migrations/0001_initial.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as 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 'CasTicket' + db.create_table(u'authentic2_idp_cas_casticket', ( + (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('ticket_id', self.gf('django.db.models.fields.CharField')(max_length=64)), + ('renew', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('validity', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('service', self.gf('django.db.models.fields.CharField')(max_length=256)), + ('user', self.gf('django.db.models.fields.CharField')(max_length=128, null=True, blank=True)), + ('creation', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('expire', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)), + ('session_key', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=64, blank=True)), + )) + db.send_create_signal(u'authentic2_idp_cas', ['CasTicket']) + + # Adding model 'CasService' + db.create_table(u'authentic2_idp_cas_casservice', ( + (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('logout_url', self.gf('django.db.models.fields.URLField')(max_length=255, null=True, blank=True)), + ('logout_use_iframe', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('logout_use_iframe_timeout', self.gf('django.db.models.fields.PositiveIntegerField')(default=300)), + ('name', self.gf('django.db.models.fields.CharField')(unique=True, max_length=128)), + ('slug', self.gf('django.db.models.fields.SlugField')(unique=True, max_length=128)), + ('domain', self.gf('django.db.models.fields.CharField')(unique=True, max_length=128)), + )) + db.send_create_signal(u'authentic2_idp_cas', ['CasService']) + + + def backwards(self, orm): + # Deleting model 'CasTicket' + db.delete_table(u'authentic2_idp_cas_casticket') + + # Deleting model 'CasService' + db.delete_table(u'authentic2_idp_cas_casservice') + + + models = { + u'authentic2_idp_cas.casservice': { + 'Meta': {'object_name': 'CasService'}, + 'domain': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '128'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'logout_url': ('django.db.models.fields.URLField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'logout_use_iframe': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'logout_use_iframe_timeout': ('django.db.models.fields.PositiveIntegerField', [], {'default': '300'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '128'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '128'}) + }, + u'authentic2_idp_cas.casticket': { + 'Meta': {'object_name': 'CasTicket'}, + 'creation': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'expire': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'renew': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'service': ('django.db.models.fields.CharField', [], {'max_length': '256'}), + 'session_key': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '64', 'blank': 'True'}), + 'ticket_id': ('django.db.models.fields.CharField', [], {'max_length': '64'}), + 'user': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), + 'validity': ('django.db.models.fields.BooleanField', [], {'default': 'False'}) + } + } + + complete_apps = ['authentic2_idp_cas'] \ No newline at end of file diff --git a/authentic2_idp_cas/migrations/__init__.py b/authentic2_idp_cas/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/authentic2_idp_cas/models.py b/authentic2_idp_cas/models.py index 7e1985e..b146575 100644 --- a/authentic2_idp_cas/models.py +++ b/authentic2_idp_cas/models.py @@ -1,11 +1,10 @@ -from datetime import timedelta - from django.db import models from django.utils.translation import ugettext_lazy as _ +from django.utils.timezone import now from authentic2.models import LogoutUrlAbstract -from . import app_setting, managers +from . import managers class CasTicket(models.Model): @@ -15,10 +14,11 @@ class CasTicket(models.Model): renew = models.BooleanField(default=False) validity = models.BooleanField(default=False) service = models.CharField(max_length=256) - user = models.CharField(max_length=128,blank=True,null=True) + user = models.CharField(max_length=128, blank=True, null=True) creation = models.DateTimeField(auto_now_add=True) '''Duration length for the ticket as seconds''' expire = models.DateTimeField(blank=True, null=True) + session_key = models.CharField(max_length=64, db_index=True, blank=True) objects = managers.CasTicketManager() @@ -38,5 +38,9 @@ class CasService(LogoutUrlAbstract): domain = models.CharField(max_length=128, unique=True, verbose_name=_('domain')) - class Meta: + objects = managers.CasServiceManager() + + class Meta: + verbose_name = _('cas service') + verbose_name_plural = _('cas services') diff --git a/authentic2_idp_cas/templates/authentic2_idp_cas/logout_fragment.html b/authentic2_idp_cas/templates/authentic2_idp_cas/logout_fragment.html new file mode 100644 index 0000000..f9294f0 --- /dev/null +++ b/authentic2_idp_cas/templates/authentic2_idp_cas/logout_fragment.html @@ -0,0 +1,10 @@ +{% load i18n %} +
{% blocktrans %}Sending logout to {{ name }}....{% endblocktrans %} + {% if needs_iframe %} + + {% else %} + + {% endif %} +
diff --git a/authentic2_idp_cas/urls.py b/authentic2_idp_cas/urls.py index cc3dbcd..123b2e2 100644 --- a/authentic2_idp_cas/urls.py +++ b/authentic2_idp_cas/urls.py @@ -3,4 +3,4 @@ from django.conf.urls import patterns, include from . import app_settings urlpatterns = patterns('', - ('^idp/cas/', include(app_settings.PROVIDER()().url))) + ('^idp/cas/', include(app_settings.PROVIDER().url))) diff --git a/authentic2_idp_cas/utils.py b/authentic2_idp_cas/utils.py new file mode 100644 index 0000000..c4bc3f9 --- /dev/null +++ b/authentic2_idp_cas/utils.py @@ -0,0 +1,37 @@ +import random +import string +import urlparse +import urllib + +ALPHABET = string.letters+string.digits+'-' + +def get_logout_urls(request): + '''Retrieve logout urls''' + return () + +def make_id(prefix='', length=29): + '''Generate CAS tickets identifiers''' + l = length-len(prefix) + content = ( random.SystemRandom().choice(ALPHABET) for x in range(l) ) + return prefix + ''.join(content) + +def url_overwrite_parameters(url, **kwargs): + splitted_url = urlparse.urlsplit(url) + query = splitted_url.query + parsed_query = urlparse.parse_qsl(query) + parsed_query = [(a,b) for a, b in parsed_query if a not in kwargs] + for a, b in kwargs.iteritems(): + parsed_query.append((a, unicode(b).encode('utf-8'))) + query = urllib.urlencode(parsed_query) + splitted_url = splitted_url[:3] + (query,) + splitted_url[4:] + return urlparse.urlunsplit(splitted_url) + +def url_add_parameters(url, **kwargs): + splitted_url = urlparse.urlsplit(url) + query = splitted_url.query + parsed_query = urlparse.parse_qsl(query) + for a, b in kwargs.iteritems(): + parsed_query.append((a, unicode(b).encode('utf-8'))) + query = urllib.urlencode(parsed_query) + splitted_url = splitted_url[:3] + (query,) + splitted_url[4:] + return urlparse.urlunsplit(splitted_url) diff --git a/authentic2_idp_cas/views.py b/authentic2_idp_cas/views.py index 3638d8a..2ab93b5 100644 --- a/authentic2_idp_cas/views.py +++ b/authentic2_idp_cas/views.py @@ -1,8 +1,6 @@ import urlparse import logging -import random import datetime -import string from xml.etree import ElementTree as ET from django.http import HttpResponseRedirect, HttpResponseBadRequest, \ @@ -21,15 +19,13 @@ from constants import SERVICE_PARAM, RENEW_PARAM, GATEWAY_PARAM, ID_PARAM, \ CANCEL_PARAM, SERVICE_TICKET_PREFIX, TICKET_PARAM, \ CAS10_VALIDATION_FAILURE, CAS10_VALIDATION_SUCCESS, PGT_URL_PARAM, \ INVALID_REQUEST_ERROR, INVALID_TICKET_ERROR, INVALID_SERVICE_ERROR, \ - INTERNAL_ERROR, CAS20_VALIDATION_FAILURE, CAS20_VALIDATION_SUCCESS, \ + INTERNAL_ERROR, CAS20_VALIDATION_FAILURE, \ CAS_NAMESPACE, USER_ELT, SERVICE_RESPONSE_ELT, AUTHENTICATION_SUCCESS_ELT -from . import models +from . import models, utils, app_settings logger = logging.getLogger(__name__) -ALPHABET = string.letters+string.digits+'-' - SAML_RESPONSE_TEMPLATE = ''' @@ -79,21 +75,18 @@ class CasProvider(object): url('^logout/$', self.logout)) url = property(get_url) - def make_id(self, prefix='', length=29): - l = length-len(prefix) - content = ( random.SystemRandom().choice(ALPHABET) for x in range(l) ) - return prefix + ''.join(content) - def create_service_ticket(self, service, renew=False, validity=True, - expire=None, user=None): + expire=None, user=None, session_key=''): '''Create a fresh service ticket''' validity = validity and not renew - return CasTicket.objects.create(ticket_id=self.make_id(prefix='ST-'), + return CasTicket.objects.create( + ticket_id=utils.make_id(prefix='ST-'), service=service, renew=renew, validity=validity, expire=None, - user=user) + user=user, + session_key=session_key) def check_authentication(self, request, st): ''' @@ -120,11 +113,17 @@ class CasProvider(object): if not service.startswith('http://') and not \ service.startswith('https://'): return self.failure(request, 'service is not an HTTP or HTTPS URL') - scheme, domain, x, x, x, x = urlparse.urlparse(service) - try: - cas_service = models.CasService.get(domain=domain) - except models.CasService.DoesNotExist: - self.failure(request, 'service %r is not allowed' % service) + + # verify service is authorized ! + for allowed_service in app_settings.SERVICES: + if service.startswith(allowed_service): + break + else: + scheme, domain, x, x, x, x = urlparse.urlparse(service) + try: + cas_service = models.CasService.get(domain=domain) + except models.CasService.DoesNotExist: + self.failure(request, 'service %r is not allowed' % service) return self.handle_login(request, cas_service, service, renew, gateway) def must_authenticate(self, request, renew): @@ -171,7 +170,8 @@ renew:%s and gateway:%s' % (service, renew, gateway)) return self.authenticate(request, st, passive=gateway) else: st = self.create_service_ticket(service, expire=expire, - user=self.get_cas_user(request)) + user=self.get_cas_user(request), + session_key=request.session.session_key) return self.handle_login_after_authentication(request, st) def cas_failure(self, request, st, reason): @@ -228,6 +228,7 @@ renew:%s and gateway:%s' % (service, renew, gateway)) # normal login st.user = self.get_cas_user(request) st.validity = True + st.session_key = request.session.session_key st.save() return self.handle_login_after_authentication(request, st) @@ -385,7 +386,12 @@ renew:%s and gateway:%s' % (service, renew, gateway)) return self.cas20_error(INTERNAL_ERROR) def logout(self, request): - return HttpResponseRedirect(settings.LOGOUT_URL) + url = request.REQUEST.get('url') + logout_url = settings.LOGOUT_URL + if url: + logout_url = utils.url_add_parameters(logout_url, + next=url) + return HttpResponseRedirect(logout_url) class Authentic2CasProvider(CasProvider): def authenticate(self, request, st, passive=False): @@ -408,6 +414,7 @@ is possible''') ae = AuthenticationEvent.objects.get(nonce=st.ticket_id) st.user = ae.who st.validity = True + st.session_key = request.session.session_key st.save() return True except AuthenticationEvent.DoesNotExist: