commit c910ca04be604231286625cccd73aaa6e045ab71 Author: Benjamin Dauvergne Date: Mon Mar 10 09:29:12 2014 +0100 first commit diff --git a/authentic2_idp_cas/__init__.py b/authentic2_idp_cas/__init__.py new file mode 100644 index 0000000..ae88d52 --- /dev/null +++ b/authentic2_idp_cas/__init__.py @@ -0,0 +1,32 @@ +from django.utils.timezone import now +from django.template.loader import render_to_string + + +class Plugin(object): + def get_before_urls(self): + from . import urls + return urls.urlpatterns + + def get_apps(self): + 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() + ctx = { + 'needs_iframe': client.logout_use_iframe, + 'name': name, + 'url': url, + 'iframe_timeout': client.logout_use_iframe_timeout, + } + content = render_to_string('idp/saml/logout_fragment.html', ctx) + l.append(content) + return l diff --git a/authentic2_idp_cas/app_settings.py b/authentic2_idp_cas/app_settings.py new file mode 100644 index 0000000..4560fd4 --- /dev/null +++ b/authentic2_idp_cas/app_settings.py @@ -0,0 +1,32 @@ +from django.utils.importlib import import_module + +class AppSettings(object): + + def __init__(self, prefix): + self.prefix = prefix + + @property + def PROVIDER(self): + 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) + + + +# Ugly? Guido recommends this himself ... +# http://mail.python.org/pipermail/python-ideas/2012-May/014969.html +import sys +app_settings = AppSettings('A2_IDP_CAS_') +app_settings.__name__ = __name__ +sys.modules[__name__] = app_settings diff --git a/authentic2_idp_cas/constants.py b/authentic2_idp_cas/constants.py new file mode 100644 index 0000000..b90c10b --- /dev/null +++ b/authentic2_idp_cas/constants.py @@ -0,0 +1,58 @@ +# Constants # +CAS_NAMESPACE = 'http://www.yale.edu/tp/cas' +RENEW_PARAM = 'renew' +SERVICE_PARAM = 'service' +GATEWAY_PARAM = 'gateway' +WARN_PARAM = 'warn' +URL_PARAM = 'url' +TICKET_PARAM = 'ticket' +PGT_URL_PARAM = 'pgtUrl' +PGT_PARAM = 'pgt' +PGT_ID_PARAM = 'pgtId' +PGT_IOU_PARAM = 'pgtIou' +TARGET_SERVICE_PARAM = 'targetService' +USERNAME_FIELD = 'username' # unused +PASSWORD_FIELD = 'password' # unused +LT_FIELD = 'lt' # unused +SERVICE_TICKET_PREFIX = 'ST-' +ID_PARAM = 'id' +CANCEL_PARAM = 'cancel' + +# ERROR codes +INVALID_REQUEST_ERROR = 'INVALID_REQUEST' +INVALID_TICKET_ERROR = 'INVALID_TICKET' +INVALID_SERVICE_ERROR = 'INVALID_SERVICE' +INTERNAL_ERROR = 'INTERNAL_ERROR' +BAD_PGT_ERROR = 'BAD_PGT' + +# XML Elements for CAS 2.0 +SERVICE_RESPONSE_ELT = 'serviceResponse' + +AUTHENTICATION_SUCCESS_ELT = 'authenticationSuccess' +USER_ELT = 'user' +PGT_ELT = 'proxyGrantingTicket' +PROXIES_ELT = 'proxies' +PROXY_ELT = 'proxy' + +AUTHENTICATION_FAILURE_ELT = 'authenticationFailure' +CODE_ELT = 'code' + +PROXY_SUCCESS_ELT = 'proxySuccess' +PROXY_TICKET_ELT = 'proxyTicket' + +PROXY_FAILURE_ELT = 'proxyFailure' + +# Templates + +CAS10_VALIDATION_FAILURE = 'no\n\n' +CAS10_VALIDATION_SUCCESS = 'yes\n%s\n' +CAS20_VALIDATION_FAILURE = ''' + + %s + +''' +CAS20_VALIDATION_SUCCESS = ''' + + %s + +''' diff --git a/authentic2_idp_cas/managers.py b/authentic2_idp_cas/managers.py new file mode 100644 index 0000000..106819d --- /dev/null +++ b/authentic2_idp_cas/managers.py @@ -0,0 +1,22 @@ +import datetime + +from django.db.models import query +from django.utils.timezone import now + +from model_utils import managers + +from . import app_settings + + +class CasTicketQuerySet(query.QuerySet): + def clean_expired(self): + '''Remove expired tickets''' + self.filter(expire__gte=now()).delete() + + def cleanup(self): + '''Delete old tickets''' + delta = datetime.timedelta(seconds=app_settings.TICKET_EXPIRATION) + qs = self.filter(creation__lt=now()-delta) + qs.delete() + +CasTicketManager = managers.PassThroughManager.for_queryset_class(CasTicketQuerySet) diff --git a/authentic2_idp_cas/models.py b/authentic2_idp_cas/models.py new file mode 100644 index 0000000..7e1985e --- /dev/null +++ b/authentic2_idp_cas/models.py @@ -0,0 +1,42 @@ +from datetime import timedelta + +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from authentic2.models import LogoutUrlAbstract + +from . import app_setting, managers + + +class CasTicket(models.Model): + '''Session ticket with a CAS 1.0 or 2.0 consumer''' + + ticket_id = models.CharField(max_length=64) + 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) + creation = models.DateTimeField(auto_now_add=True) + '''Duration length for the ticket as seconds''' + expire = models.DateTimeField(blank=True, null=True) + + objects = managers.CasTicketManager() + + def valid(self): + return self.validity and not self.expired() + + def expired(self): + '''Check if the given CAS ticket has expired''' + if self.expire: + return now() >= self.expire + else: + return False + +class CasService(LogoutUrlAbstract): + name = models.CharField(max_length=128, unique=True, verbose_name=_('name')) + slug = models.SlugField(max_length=128, unique=True, verbose_name=_('slug')) + domain = models.CharField(max_length=128, unique=True, + verbose_name=_('domain')) + + class Meta: + diff --git a/authentic2_idp_cas/tests.py b/authentic2_idp_cas/tests.py new file mode 100644 index 0000000..c1750f3 --- /dev/null +++ b/authentic2_idp_cas/tests.py @@ -0,0 +1,59 @@ +from xml.etree import ElementTree as ET + + +from django.test import TestCase +from django.test.client import RequestFactory + + +from authentic2.compat import get_user_model +from .models import CasTicket +from . import views +from . import constants + + +class CasTests(TestCase): + LOGIN = 'test' + PASSWORD = 'test' + + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user(self.LOGIN, password=self.PASSWORD) + self.factory = RequestFactory() + + def test_service_validate_with_default_attributes(self): + CasTicket.objects.create( + ticket_id='ST-xxx', + service='yyy', + user=self.user, + validity=True) + request = self.factory.get('/idp/cas/serviceValidate', + {'service': 'yyy', 'ticket': 'ST-xxx'}) + class TestCasProvider(views.CasProvider): + def get_attributes(self, request, st): + assert st.service == 'yyy' + assert st.ticket_id == 'ST-xxx' + return { 'username': 'bob', 'email': 'bob@example.com' }, 'default' + provider = TestCasProvider() + response = provider.service_validate(request) + print response.content + root = ET.fromstring(response.content) + ns_ctx = { 'cas': constants.CAS_NAMESPACE } + user_elt = root.find('cas:authenticationSuccess/cas:utilisateur', namespaces=ns_ctx) + assert user_elt is not None + + def test_service_validate_with_custom_attributes(self): + CasTicket.objects.create( + ticket_id='ST-xxx', + service='yyy', + user=self.user, + validity=True) + request = self.factory.get('/idp/cas/serviceValidate', + {'service': 'yyy', 'ticket': 'ST-xxx'}) + class TestCasProvider(views.CasProvider): + def get_attributes(self, request, st): + assert st.service == 'yyy' + assert st.ticket_id == 'ST-xxx' + return { 'username': 'bob', 'email': 'bob@example.com' }, 'utilisateur' + provider = TestCasProvider() + response = provider.service_validate(request) + print response.content diff --git a/authentic2_idp_cas/urls.py b/authentic2_idp_cas/urls.py new file mode 100644 index 0000000..cc3dbcd --- /dev/null +++ b/authentic2_idp_cas/urls.py @@ -0,0 +1,6 @@ +from django.conf.urls import patterns, include + +from . import app_settings + +urlpatterns = patterns('', + ('^idp/cas/', include(app_settings.PROVIDER()().url))) diff --git a/authentic2_idp_cas/views.py b/authentic2_idp_cas/views.py new file mode 100644 index 0000000..47d091e --- /dev/null +++ b/authentic2_idp_cas/views.py @@ -0,0 +1,414 @@ +import urlparse +import logging +import random +import datetime +import string +from xml.etree import ElementTree as ET + +from django.http import HttpResponseRedirect, HttpResponseBadRequest, \ + HttpResponse, HttpResponseNotAllowed +from django.core.urlresolvers import reverse +from django.contrib.auth.views import redirect_to_login, logout +from django.utils.http import urlquote, urlencode +from django.conf.urls import patterns, url +from django.conf import settings + +from models import CasTicket +from authentic2.views import redirect_to_login as \ + auth2_redirect_to_login +from authentic2.models import AuthenticationEvent +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, \ + CAS_NAMESPACE, USER_ELT, SERVICE_RESPONSE_ELT, AUTHENTICATION_SUCCESS_ELT + +from . import models + +logger = logging.getLogger(__name__) + +ALPHABET = string.letters+string.digits+'-' + +SAML_RESPONSE_TEMPLATE = ''' + + + + + + + + + + + + {audience} + + + + + {name_id} + + urn:oasis:names:tc:SAML:1.0:cm:artifact + + + {attributes} + + + + + {name_id} + + urn:oasis:names:tc:SAML:1.0:cm:artifact + + + + + + +''' + +class CasProvider(object): + def get_url(self): + return patterns('cas', + url('^login/$', self.login), + url('^continue/$', self.continue_cas), + url('^validate/$', self.validate), + url('^serviceValidate/$', self.service_validate), + url('^samlValidate/$', self.saml_validate), + 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): + '''Create a fresh service ticket''' + validity = validity and not renew + return CasTicket.objects.create(ticket_id=self.make_id(prefix='ST-'), + service=service, + renew=renew, + validity=validity, + expire=None, + user=user) + + def check_authentication(self, request, st): + ''' + Check that the given service ticket is linked to an authentication + event. + ''' + return False + + def failure(self, request, reason): + ''' + Return a HTTP 400 code with the @reason argument as content. + ''' + return HttpResponseBadRequest(content=reason) + + def login(self, request): + if request.method != 'GET': + return HttpResponseBadRequest('Only GET HTTP verb is accepted') + service = request.GET.get(SERVICE_PARAM) + renew = request.GET.get(RENEW_PARAM) is not None + gateway = request.GET.get(GATEWAY_PARAM) is not None + + if not service: + return self.failure(request, 'no service field') + 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) + return self.handle_login(request, cas_service, service, renew, gateway) + + def must_authenticate(self, request, renew): + '''Does the user needs to authenticate ? + + You can refer to the current request and to the renew parameter from + the login reuest. + + Returns a boolean. + ''' + return not request.user.is_authenticated() or renew + + def get_cas_user(self, request): + '''Return an ascii string representing the user. + + It should usually be the uid from an user record in a LDAP + ''' + return request.user.username + + def handle_login(self, request, cas_service, service, renew, gateway, + duration=None): + ''' + Handle a login request + + @service: URL where the CAS ticket will be returned + @renew: whether to re-authenticate the user + @gateway: do not let the IdP interact with the user + + It is an extension point + ''' + logger.debug('Handling CAS login for service:%r with parameters \ +renew:%s and gateway:%s' % (service, renew, gateway)) + + if duration is None or duration < 0: + duration = 5*60 + if duration: + expire = datetime.datetime.now() + \ + datetime.timedelta(seconds=duration) + else: + expire = None + if self.must_authenticate(request, renew): + st = self.create_service_ticket(service, validity=False, + renew=renew, expire=expire) + return self.authenticate(request, st, passive=gateway) + else: + st = self.create_service_ticket(service, expire=expire, + user=self.get_cas_user(request)) + return self.handle_login_after_authentication(request, st) + + def cas_failure(self, request, st, reason): + logger.debug('%s, redirecting without ticket to %r' % (reason, \ + st.service)) + st.delete() + return HttpResponseRedirect(st.service) + + def authenticate(self, request, st, passive=False): + ''' + Redirect to an login page, pass a cookie to the login page to + associate the login event with the service ticket, if renew was + asked + + It is an extension point. If your application support some passive + authentication, it must be tried here instead of failing. + @request: current django request + @st: a currently invalid service ticket + @passive: whether we can interact with the user + ''' + + if passive: + return self.cas_failure(request, st, + 'user needs to log in and gateway is True') + if st.renew: + raise NotImplementedError('renew is not implemented') + return redirect_to_login(next='%s?id=%s' % (reverse(self.continue_cas), + urlquote(st.ticket_id))) + + def continue_cas(self, request): + '''Continue CAS login after authentication''' + ticket_id = request.GET.get(ID_PARAM) + cancel = request.GET.get(CANCEL_PARAM) is not None + if ticket_id is None: + return self.failure(request, 'missing ticket id') + if not ticket_id.startswith(SERVICE_TICKET_PREFIX): + return self.failure(request, 'invalid ticket id') + try: + st = CasTicket.objects.get(ticket_id=ticket_id) + except CasTicket.DoesNotExist: + return self.failure(request, 'unknown ticket id') + if cancel: + return self.cas_failure(request, st, 'login cancelled') + if st.renew: + # renew login + if self.check_authentication(request, st): + return self.handle_login_after_authentication(request, st) + else: + return self.authenticate(request, st) + elif self.must_authenticate(request, False): + # not logged ? Yeah do it again! + return self.authenticate(request, st) + else: + # normal login + st.user = self.get_cas_user(request) + st.validity = True + st.save() + return self.handle_login_after_authentication(request, st) + + def handle_login_after_authentication(self, request, st): + if not st.valid(): + return self.cas_failure(request, st, + 'service ticket id is not valid') + else: + return self.return_ticket(request, st) + + def return_ticket(self, request, st): + if '?' in st.service: + return HttpResponseRedirect('%s&ticket=%s' % (st.service,st.ticket_id)) + else: + return HttpResponseRedirect('%s?ticket=%s' % (st.service,st.ticket_id)) + + def validate(self, request): + if request.method != 'GET': + return self.failure(request, 'Only GET HTTP verb is accepted') + service = request.GET.get(SERVICE_PARAM) + ticket = request.GET.get(TICKET_PARAM) + renew = request.GET.get(RENEW_PARAM) is not None + if service is None: + return self.failure(request, 'service parameter is missing') + if service is None: + return self.failure(request, 'ticket parameter is missing') + if not ticket.startswith(SERVICE_TICKET_PREFIX): + return self.failure(request, 'invalid ticket prefix') + try: + st = CasTicket.objects.get(ticket_id=ticket) + st.delete() + except CasTicket.DoesNotExist: + st = None + if st is None \ + or not st.valid() \ + or (st.renew ^ renew) \ + or st.service != service: + return HttpResponse(CAS10_VALIDATION_FAILURE) + else: + return HttpResponse(CAS10_VALIDATION_SUCCESS % st.user) + + def get_cas20_error_message(self, code): + return '' # FIXME + + def cas20_error(self, request, code): + message = self.get_cas20_error_message(code) + return HttpResponse(CAS20_VALIDATION_FAILURE % (code, message), + content_type='text/xml') + + def get_attributes(self, request, st): + # XXX: st.service contains the requesting service URL, use it to match CAS attribute policy + return {}, False + + def saml_build_attributes(self, request, st): + attributes, section = self.get_attributes(request, st) + result = [] + for key, value in attributes.iteritems(): + key = key.encode('utf-8') + value = value.encode('utf-8') + result.append(''' +{value} +'''.format(key=key, value=value)) + return ''.join(result) + + def saml_validate(self, request): + if request.method != 'POST': + return HttpResponseNotAllowed(['POST']) + root = ET.fromstring(request.body) + ns = dict( + SOAP_ENV = 'http://schemas.xmlsoap.org/soap/envelope/', + samlp = 'urn:oasis:names:tc:SAML:1.0:protocol') + if root.tag != '{%(SOAP_ENV)s}Envelope' % ns: + return self.saml_error(request, INVALID_REQUEST_ERROR) + assertion_artifact = root.find('{%(SOAP_ENV)s}Body/{%(samlp)s}Request/{%(samlp)s}AssertionArtifact') + ticket = assertion_artifact.text + try: + st = CasTicket.objects.get(ticket_id=ticket) + st.delete() + except CasTicket.DoesNotExist: + st = None + if st is None or not st.valid(): + return self.saml_error(request, INVALID_TICKET_ERROR) + new_id = self.generate_id() + + ctx = { + 'response_id': new_id, + 'assertion_id': new_id, + 'issue_instant': '', # XXX: iso time + 'issuer': request.build_absolute_uri('/'), + 'not_before': '', # XXX: issue time - lag + 'not_on_or_after': '', # XXX issue time + lag, + 'audience': st.service.encode('utf-8'), + 'name_id': request.user.username, + 'attributes': self.saml_build_attributes(request, st), + } + return HttpResponse(SAML_RESPONSE_TEMPLATE.format(**ctx), + content_type='text/xml') + + def service_validate_success_response(self, request, st): + attributes, section = self.get_attributes(request, st) + try: + ET.register_namespace('cas', 'http://www.yale.edu/tp/cas') + except AttributeError: + ET._namespace_map['http://www.yale.edu/tp/cas'] = 'cas' + root = ET.Element('{%s}%s' % (CAS_NAMESPACE, SERVICE_RESPONSE_ELT)) + success = ET.SubElement(root, '{%s}%s' % (CAS_NAMESPACE, AUTHENTICATION_SUCCESS_ELT)) + if attributes: + if section == 'default': + user = success + else: + user = ET.SubElement(success, '{%s}%s' % (CAS_NAMESPACE, section)) + for key, value in attributes.iteritems(): + elt = ET.SubElement(user, '{%s}%s' % (CAS_NAMESPACE, key)) + elt.text = unicode(value) + else: + user = ET.SubElement(success, '{%s}%s' % (CAS_NAMESPACE, USER_ELT)) + user.text = unicode(st.user) + return HttpResponse(ET.tostring(root, encoding='utf8'), + content_type='text/xml') + + def service_validate(self, request): + ''' + CAS 2.0 serviceValidate endpoint. + ''' + try: + if request.method != 'GET': + return self.failure('Only GET HTTP verb is accepted') + service = request.GET.get(SERVICE_PARAM) + ticket = request.GET.get(TICKET_PARAM) + renew = request.GET.get(RENEW_PARAM) is not None + pgt_url = request.GET.get(PGT_URL_PARAM) + if service is None: + return self.cas20_error(request, INVALID_REQUEST_ERROR) + if service is None: + return self.cas20_error(request, INVALID_REQUEST_ERROR) + if not ticket.startswith(SERVICE_TICKET_PREFIX): + return self.cas20_error(request, INVALID_TICKET_ERROR) + try: + st = CasTicket.objects.get(ticket_id=ticket) + st.delete() + except CasTicket.DoesNotExist: + st = None + if st is None \ + or not st.valid() \ + or (st.renew ^ renew): + return self.cas20_error(request, INVALID_TICKET_ERROR) + if st.service != service: + return self.cas20_error(request, INVALID_SERVICE_ERROR) + if pgt_url: + raise NotImplementedError( + 'CAS 2.0 pgtUrl parameter is not handled') + return self.service_validate_success_response(request, st) + except Exception: + logger.exception('error in cas:service_validate') + return self.cas20_error(INTERNAL_ERROR) + + def logout(self, request): + return HttpResponseRedirect(settings.LOGOUT_URL) + +class Authentic2CasProvider(CasProvider): + def authenticate(self, request, st, passive=False): + next = '%s?id=%s' % (reverse(self.continue_cas), + urlquote(st.ticket_id)) + if passive: + if getattr(settings, 'AUTH_SSL', False): + query = { 'next': next, + 'nonce': st.ticket_id } + return HttpResponseRedirect('%s?%s' % + (reverse('user_signin_ssl'), urlencode(query))) + else: + return self.cas_failure(request, st, + '''user needs to login and no passive authentication \ +is possible''') + return auth2_redirect_to_login(request, next=next, nonce=st.ticket_id) + + def check_authentication(self, request, st): + try: + ae = AuthenticationEvent.objects.get(nonce=st.ticket_id) + st.user = ae.who + st.validity = True + st.save() + return True + except AuthenticationEvent.DoesNotExist: + return False diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..c86fe34 --- /dev/null +++ b/setup.py @@ -0,0 +1,20 @@ +#!/usr/bin/python +from setuptools import setup, find_packages +import os + +setup(name='authentic2-idp-cas', + version='1.0', + license='AGPLv3', + description='Authentic2 IdP CAS', + author="Entr'ouvert", + author_email="info@entrouvert.com", + packages=find_packages(os.path.dirname(__file__) or '.'), + install_requires=[ + 'djangorestframework', + ], + entry_points={ + 'authentic2.plugin': [ + 'authentic-idp-cas = authentic2_idp_cas:Plugin', + ], + }, +)