summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBenjamin Dauvergne <bdauvergne@entrouvert.com>2014-03-10 08:29:12 (GMT)
committerBenjamin Dauvergne <bdauvergne@entrouvert.com>2014-03-10 08:29:12 (GMT)
commitc910ca04be604231286625cccd73aaa6e045ab71 (patch)
tree580bf824b1c465c4891839825f285a7950e0482e
downloadauthentic2-idp-cas-c910ca04be604231286625cccd73aaa6e045ab71.zip
authentic2-idp-cas-c910ca04be604231286625cccd73aaa6e045ab71.tar.gz
authentic2-idp-cas-c910ca04be604231286625cccd73aaa6e045ab71.tar.bz2
first commit
-rw-r--r--authentic2_idp_cas/__init__.py32
-rw-r--r--authentic2_idp_cas/app_settings.py32
-rw-r--r--authentic2_idp_cas/constants.py58
-rw-r--r--authentic2_idp_cas/managers.py22
-rw-r--r--authentic2_idp_cas/models.py42
-rw-r--r--authentic2_idp_cas/tests.py59
-rw-r--r--authentic2_idp_cas/urls.py6
-rw-r--r--authentic2_idp_cas/views.py414
-rwxr-xr-xsetup.py20
9 files changed, 685 insertions, 0 deletions
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 = '''<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
+ <cas:authenticationFailure code="%s">
+ %s
+ </cas:authenticationFailure>
+</cas:serviceResponse>'''
+CAS20_VALIDATION_SUCCESS = '''<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
+ <cas:authenticationSuccess>
+ <cas:user>%s</cas:user>
+ </cas:authenticationSuccess>
+</cas:serviceResponse>'''
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 = '''<?xml version="1.0" encoding="UTF-8"?>
+<SOAP-ENV:Envelope SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/">
+<SOAP-ENV:Header/>
+<SOAP-ENV:Body>
+<Response xmlns="urn:oasis:names:tc:SAML:1.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:1.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:1.0:protocol" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" IssueInstant="2013-05-16T16:07:35Z" MajorVersion="1" MinorVersion="1" Recipient="https://amonecole.monreseau.lan/webcalendar/login.php" ResponseID="{reponse_id}">
+ <Status>
+ <StatusCode Value="samlp:Success">
+ </StatusCode>
+ </Status>
+ <Assertion xmlns="urn:oasis:names:tc:SAML:1.0:assertion" AssertionID="{assertion_id}" IssueInstant="{issue_instant}" Issuer="{issuer}" MajorVersion="1" MinorVersion="1">
+<Conditions NotBefore="{not_before}" NotOnOrAfter="{not_on_or_after}">
+ <AudienceRestrictionCondition>
+ <Audience>{audience}</Audience>
+ </AudienceRestrictionCondition>
+ </Conditions>
+ <AttributeStatement>
+ <Subject>
+ <NameIdentifier>{name_id}</NameIdentifier>
+ <SubjectConfirmation>
+ <ConfirmationMethod>urn:oasis:names:tc:SAML:1.0:cm:artifact</ConfirmationMethod>
+ </SubjectConfirmation>
+ </Subject>
+ {attributes}
+
+ </AttributeStatement>
+ <AuthenticationStatement AuthenticationInstant="{authentication_instant}" AuthenticationMethod="{authentication_method}">
+ <Subject>
+ <NameIdentifier>{name_id}</NameIdentifier>
+ <SubjectConfirmation>
+ <ConfirmationMethod>urn:oasis:names:tc:SAML:1.0:cm:artifact</ConfirmationMethod>
+ </SubjectConfirmation>
+ </Subject>
+ </AuthenticationStatement>
+ </Assertion>
+</Response>
+</SOAP-ENV:Body>
+</SOAP-ENV:Envelope>'''
+
+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('''<Attribute AttributeName="{key}" AttributeNamespace="http://www.ja-sig.org/products/cas/">
+<AttributeValue>{value}</AttributeValue>
+</Attribute>'''.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',
+ ],
+ },
+)