From ff74a0796507842a2dbe20aebe548dea602a9dc8 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Fri, 29 May 2015 19:27:54 +0200 Subject: [PATCH] Use only classe view, put ticket prefix as config option --- cas_server/default_settings.py | 8 + cas_server/models.py | 3 + cas_server/urls.py | 14 +- cas_server/utils.py | 9 +- cas_server/views.py | 505 +++++++++++++++++++-------------- 5 files changed, 315 insertions(+), 224 deletions(-) diff --git a/cas_server/default_settings.py b/cas_server/default_settings.py index 22caa2d..33b6ce7 100644 --- a/cas_server/default_settings.py +++ b/cas_server/default_settings.py @@ -27,6 +27,11 @@ setting_default('CAS_TICKET_VALIDITY', 300) setting_default('CAS_TICKET_TIMEOUT', 24*3600) setting_default('CAS_PROXY_CA_CERTIFICATE_PATH', True) +setting_default('CAS_SERVICE_TICKET_PREFIX', 'ST') +setting_default('CAS_PROXY_TICKET_PREFIX', 'PT') +setting_default('CAS_PROXY_GRANTING_TICKET_PREFIX', 'PGT') +setting_default('CAS_PROXY_GRANTING_TICKET_IOU_PREFIX', 'PGTIOU') + setting_default('CAS_SQL_HOST', 'localhost') setting_default('CAS_SQL_USERNAME', '') setting_default('CAS_SQL_PASSWORD', '') @@ -36,3 +41,6 @@ setting_default('CAS_SQL_USER_QUERY', 'SELECT user AS usersame, pass AS ' \ 'password, users.* FROM users WHERE user = %s') setting_default('CAS_SQL_PASSWORD_CHECK', 'crypt') # crypt or plain +def noop(): + """do nothing""" + pass diff --git a/cas_server/models.py b/cas_server/models.py index 3a36e86..2e72263 100644 --- a/cas_server/models.py +++ b/cas_server/models.py @@ -357,16 +357,19 @@ class Ticket(models.Model): class ServiceTicket(Ticket): """A Service Ticket""" + PREFIX = settings.CAS_SERVICE_TICKET_PREFIX value = models.CharField(max_length=255, default=utils.gen_st, unique=True) def __unicode__(self): return u"ServiceTicket(%s, %s, %s)" % (self.user, self.value, self.service) class ProxyTicket(Ticket): """A Proxy Ticket""" + PREFIX = settings.CAS_PROXY_TICKET_PREFIX value = models.CharField(max_length=255, default=utils.gen_pt, unique=True) def __unicode__(self): return u"ProxyTicket(%s, %s, %s)" % (self.user, self.value, self.service) class ProxyGrantingTicket(Ticket): """A Proxy Granting Ticket""" + PREFIX = settings.CAS_PROXY_GRANTING_TICKET_PREFIX value = models.CharField(max_length=255, default=utils.gen_pgt, unique=True) def __unicode__(self): return u"ProxyGrantingTicket(%s, %s, %s)" % (self.user, self.value, self.service) diff --git a/cas_server/urls.py b/cas_server/urls.py index 2aeaa5b..3cba967 100644 --- a/cas_server/urls.py +++ b/cas_server/urls.py @@ -20,12 +20,12 @@ urlpatterns = patterns( url(r'^$', RedirectView.as_view(pattern_name="login")), url('^login$', views.LoginView.as_view(), name='login'), url('^logout$', views.LogoutView.as_view(), name='logout'), - url('^validate$', views.validate, name='validate'), - url('^serviceValidate$', views.service_validate, name='serviceValidate'), - url('^proxyValidate$', views.proxy_validate, name='proxyValidate'), - url('^proxy$', views.proxy, name='proxy'), - url('^p3/serviceValidate$', views.p3_service_validate, name='p3_serviceValidate'), - url('^p3/proxyValidate$', views.p3_proxy_validate, name='p3_proxyValidate'), - url('^samlValidate$', views.saml_validate, name='samlValidate'), + url('^validate$', views.Validate.as_view(), name='validate'), + url('^serviceValidate$', views.ValidateService.as_view(allow_proxy_ticket=False), name='serviceValidate'), + url('^proxyValidate$', views.ValidateService.as_view(allow_proxy_ticket=True), name='proxyValidate'), + url('^proxy$', views.Proxy.as_view(), name='proxy'), + url('^p3/serviceValidate$', views.ValidateService.as_view(allow_proxy_ticket=False), name='p3_serviceValidate'), + url('^p3/proxyValidate$', views.ValidateService.as_view(allow_proxy_ticket=True), name='p3_proxyValidate'), + url('^samlValidate$', views.SamlValidate.as_view(), name='samlValidate'), ) diff --git a/cas_server/utils.py b/cas_server/utils.py index ed241df..9ca7749 100644 --- a/cas_server/utils.py +++ b/cas_server/utils.py @@ -62,20 +62,21 @@ def _gen_ticket(prefix): def gen_st(): """Generate a Service Ticket""" - return _gen_ticket('ST') + return _gen_ticket(settings.CAS_SERVICE_TICKET_PREFIX) def gen_pt(): """Generate a Proxy Ticket""" - return _gen_ticket('PT') + return _gen_ticket(settings.CAS_PROXY_TICKET_PREFIX) def gen_pgt(): """Generate a Proxy Granting Ticket""" - return _gen_ticket('PGT') + return _gen_ticket(settings.CAS_PROXY_GRANTING_TICKET_PREFIX) def gen_pgtiou(): """Generate a Proxy Granting Ticket IOU""" - return _gen_ticket('PGTIOU') + return _gen_ticket(settings.CAS_PROXY_GRANTING_TICKET_IOU_PREFIX) def gen_saml_id(): + """Generate an saml id""" return _gen_ticket('_') diff --git a/cas_server/views.py b/cas_server/views.py index d4fcc26..9f21249 100644 --- a/cas_server/views.py +++ b/cas_server/views.py @@ -12,18 +12,20 @@ """views for the app""" from . import default_settings +default_settings.noop() + from django.shortcuts import render, redirect from django.http import HttpResponse, HttpResponseRedirect from django.conf import settings from django.contrib import messages -from django.views.decorators.csrf import csrf_exempt +from django.utils.decorators import method_decorator from django.utils.translation import ugettext as _ -from django.core.urlresolvers import reverse from django.utils import timezone +from django.views.decorators.csrf import csrf_exempt + from django.views.generic import View import requests -import urllib from lxml import etree from datetime import timedelta @@ -31,8 +33,24 @@ from . import utils from . import forms from . import models +class AttributesMixin(object): + """mixin for the attributs methode""" + + # pylint: disable=too-few-public-methods + + def attributes(self): + """regerate attributes list for template rendering""" + attributes = [] + for key, value in self.ticket.attributs.items(): + if isinstance(value, list): + for elt in value: + attributes.append((key, elt)) + else: + attributes.append((key, value)) + return attributes + class LogoutMixin(object): - """destroy CAS session utims""" + """destroy CAS session utils""" def clean_session_variables(self): """Clean sessions variables""" try: @@ -263,204 +281,260 @@ class LoginView(View, LogoutMixin): else: return self.not_authenticated() -def validate(request): +class Validate(View): """service ticket validation""" - service = request.GET.get('service') - ticket = request.GET.get('ticket') - renew = True if request.GET.get('renew') else False - if service and ticket: - try: - ticket = models.ServiceTicket.objects.get( - value=ticket, - service=service, - validate=False, - renew=renew, - creation__gt=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_VALIDITY)) - ) - ticket.validate = True - ticket.save() - return HttpResponse("yes\n", content_type="text/plain") - except models.ServiceTicket.DoesNotExist: - return HttpResponse("no\n", content_type="text/plain") - else: - return HttpResponse("no\n", content_type="text/plain") - - -def _validate_error(request, code, msg=""): - """render the serviceValidateError.xml template using `code` and `msg`""" - return render( - request, - "cas_server/serviceValidateError.xml", - {'code':code, 'msg':msg}, - content_type="text/xml; charset=utf-8" - ) - -def ps_validate(request, ticket_type=None): - """factorization for serviceValidate and proxyValidate""" - if ticket_type is None: - ticket_type = ['ST'] - service = request.GET.get('service') - ticket = request.GET.get('ticket') - pgt_url = request.GET.get('pgtUrl') - renew = True if request.GET.get('renew') else False - if service and ticket: - for elt in ticket_type: - if ticket.startswith(elt): - break - else: - return _validate_error( - request, - 'INVALID_TICKET', - 'tickets should begin with %s' % ' or '.join(ticket_type) - ) - try: - proxies = [] - if ticket.startswith("ST"): + @staticmethod + def get(request): + """methode called on GET request on this view""" + service = request.GET.get('service') + ticket = request.GET.get('ticket') + renew = True if request.GET.get('renew') else False + if service and ticket: + try: ticket = models.ServiceTicket.objects.get( value=ticket, + service=service, validate=False, renew=renew, creation__gt=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_VALIDITY)) ) - elif ticket.startswith("PT"): - ticket = models.ProxyTicket.objects.get( - value=ticket, + ticket.validate = True + ticket.save() + return HttpResponse("yes\n", content_type="text/plain") + except models.ServiceTicket.DoesNotExist: + return HttpResponse("no\n", content_type="text/plain") + else: + return HttpResponse("no\n", content_type="text/plain") + + +class ValidateError(Exception): + """handle service validation error""" + def __init__(self, code, msg=""): + self.code = code + self.msg = msg + super(ValidateError).__init__(code) + + def __unicode__(self): + return u"%s" % self.msg + + def render(self, request): + """render the error template for the exception""" + return render( + request, + "cas_server/serviceValidateError.xml", + {'code':self.code, 'msg':self.msg}, + content_type="text/xml; charset=utf-8" + ) + + +class ValidateService(View, AttributesMixin): + """service ticket validation [CAS 2.0] and [CAS 3.0]""" + request = None + service = None + ticket = None + pgt_url = None + renew = None + allow_proxy_ticket = None + + def get(self, request, allow_proxy_ticket=False): + """methode called on GET request on this view""" + self.request = request + self.allow_proxy_ticket = allow_proxy_ticket + self.service = request.GET.get('service') + self.ticket = request.GET.get('ticket') + self.pgt_url = request.GET.get('pgtUrl') + self.renew = True if request.GET.get('renew') else False + + if not self.service or not self.ticket: + return ValidateError( + 'INVALID_REQUEST', + "you must specify a service and a ticket" + ).render(request) + else: + try: + self.ticket, proxies = self.process_ticket() + params = { + 'username':self.ticket.user.username, + 'attributes':self.attributes(), + 'proxies':proxies + } + if self.ticket.service_pattern.user_field and \ + self.ticket.user.attributs.get(self.ticket.service_pattern.user_field): + params['username'] = self.ticket.user.attributs.get( + self.ticket.service_pattern.user_field + ) + if self.pgt_url and self.pgt_url.startswith("https://"): + return self.process_pgturl(params) + else: + return render( + request, + "cas_server/serviceValidate.xml", + params, + content_type="text/xml; charset=utf-8" + ) + except ValidateError as error: + return error.render(request) + + + def process_ticket(self): + """fetch the ticket angains the database and check its validity""" + try: + proxies = [] + if self.ticket.startswith(models.ServiceTicket.PREFIX): + ticket = models.ServiceTicket.objects.get( + value=self.ticket, validate=False, - renew=renew, + renew=self.renew, + creation__gt=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_VALIDITY)) + ) + elif self.allow_proxy_ticket and self.ticket.startswith(models.ProxyTicket.PREFIX): + ticket = models.ProxyTicket.objects.get( + value=self.ticket, + validate=False, + renew=self.renew, creation__gt=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_VALIDITY)) ) for prox in ticket.proxies.all(): proxies.append(prox.url) + else: + raise ValidateError('INVALID_TICKET') ticket.validate = True ticket.save() - if ticket.service != service: - return _validate_error(request, 'INVALID_SERVICE') - attributes = [] - for key, value in ticket.attributs.items(): - if isinstance(value, list): - for elt in value: - attributes.append((key, elt)) - else: - attributes.append((key, value)) - params = {'username':ticket.user.username, 'attributes':attributes, 'proxies':proxies} - if ticket.service_pattern.user_field and \ - ticket.user.attributs.get(ticket.service_pattern.user_field): - params['username'] = ticket.user.attributs.get(ticket.service_pattern.user_field) - if pgt_url and pgt_url.startswith("https://"): - pattern = models.ServicePattern.validate(pgt_url) - if pattern.proxy_callback: - proxyid = utils.gen_pgtiou() - pticket = models.ProxyGrantingTicket.objects.create( - user=ticket.user, - service=pgt_url, - service_pattern=pattern, - single_log_out=pattern.single_log_out - ) - url = utils.update_url(pgt_url, {'pgtIou':proxyid, 'pgtId':pticket.value}) - try: - ret = requests.get(url, verify=settings.CAS_PROXY_CA_CERTIFICATE_PATH) - if ret.status_code == 200: - params['proxyGrantingTicket'] = proxyid - else: - pticket.delete() - return render( - request, - "cas_server/serviceValidate.xml", - params, - content_type="text/xml; charset=utf-8" - ) - except requests.exceptions.SSLError as error: - error = utils.unpack_nested_exception(error) - return _validate_error(request, 'INVALID_PROXY_CALLBACK', str(error)) - else: - return _validate_error( - request, - 'INVALID_PROXY_CALLBACK', - "callback url not allowed by configuration" - ) - else: - return render( - request, - "cas_server/serviceValidate.xml", - params, - content_type="text/xml; charset=utf-8" - ) + if ticket.service != self.service: + raise ValidateError('INVALID_SERVICE') + return ticket, proxies except (models.ServiceTicket.DoesNotExist, models.ProxyTicket.DoesNotExist): - return _validate_error(request, 'INVALID_TICKET', 'ticket not found') + raise ValidateError('INVALID_TICKET', 'ticket not found') + + + def process_pgturl(self, params): + """Handle PGT request""" + try: + pattern = models.ServicePattern.validate(self.pgt_url) + if pattern.proxy_callback: + proxyid = utils.gen_pgtiou() + pticket = models.ProxyGrantingTicket.objects.create( + user=self.ticket.user, + service=self.pgt_url, + service_pattern=pattern, + single_log_out=pattern.single_log_out + ) + url = utils.update_url(self.pgt_url, {'pgtIou':proxyid, 'pgtId':pticket.value}) + try: + ret = requests.get(url, verify=settings.CAS_PROXY_CA_CERTIFICATE_PATH) + if ret.status_code == 200: + params['proxyGrantingTicket'] = proxyid + else: + pticket.delete() + return render( + self.request, + "cas_server/serviceValidate.xml", + params, + content_type="text/xml; charset=utf-8" + ) + except requests.exceptions.SSLError as error: + error = utils.unpack_nested_exception(error) + raise ValidateError('INVALID_PROXY_CALLBACK', str(error)) + else: + raise ValidateError( + 'INVALID_PROXY_CALLBACK', + "callback url not allowed by configuration" + ) except models.ServicePattern.DoesNotExist: - return _validate_error( - request, + raise ValidateError( 'INVALID_PROXY_CALLBACK', 'callback url not allowed by configuration' ) - else: - return _validate_error( - request, - 'INVALID_REQUEST', - "you must specify a service and a ticket" - ) -def service_validate(request): - """service ticket validation CAS 2.0 (also work for CAS 3.0)""" - return ps_validate(request) -def proxy_validate(request): - """service/proxy ticket validation CAS 2.0 (also work for CAS 3.0)""" - return ps_validate(request, ["ST", "PT"]) - -def proxy(request): +class Proxy(View): """proxy ticket service""" - pgt = request.GET.get('pgt') - target_service = request.GET.get('targetService') - if pgt and target_service: + + request = None + pgt = None + target_service = None + + def get(self, request): + """methode called on GET request on this view""" + self.request = request + self.pgt = request.GET.get('pgt') + self.target_service = request.GET.get('targetService') + try: + if self.pgt and self.target_service: + return self.process_proxy() + else: + raise ValidateError( + 'INVALID_REQUEST', + "you must specify and pgt and targetService" + ) + except ValidateError as error: + return error.render(request) + + + def process_proxy(self): + """handle PT request""" try: # is the target service allowed - pattern = models.ServicePattern.validate(target_service) + pattern = models.ServicePattern.validate(self.target_service) if not pattern.proxy: - return _validate_error( - request, + raise ValidateError( 'UNAUTHORIZED_SERVICE', 'the service do not allow proxy ticket' ) # is the proxy granting ticket valid ticket = models.ProxyGrantingTicket.objects.get( - value=pgt, + value=self.pgt, creation__gt=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_VALIDITY)) ) # is the pgt user allowed on the target service pattern.check_user(ticket.user) - pticket = ticket.user.get_ticket(models.ProxyTicket, target_service, pattern, False) + pticket = ticket.user.get_ticket( + models.ProxyTicket, + self.target_service, + pattern, + renew=False) pticket.proxies.create(url=ticket.service) return render( - request, + self.request, "cas_server/proxy.xml", {'ticket':pticket.value}, content_type="text/xml; charset=utf-8" ) except models.ProxyGrantingTicket.DoesNotExist: - return _validate_error(request, 'INVALID_TICKET', 'PGT not found') + raise ValidateError('INVALID_TICKET', 'PGT not found') except models.ServicePattern.DoesNotExist: - return _validate_error(request, 'UNAUTHORIZED_SERVICE') + raise ValidateError('UNAUTHORIZED_SERVICE') except (models.BadUsername, models.BadFilter, models.UserFieldNotDefined): - return _validate_error( - request, + raise ValidateError( 'UNAUTHORIZED_USER', - '%s not allowed on %s' % (ticket.user, target_service) + '%s not allowed on %s' % (ticket.user, self.target_service) ) - else: - return _validate_error( + + + +class SamlValidateError(Exception): + """handle saml validation error""" + def __init__(self, code, msg=""): + self.code = code + self.msg = msg + super(SamlValidateError).__init__(code) + + def __unicode__(self): + return u"%s" % self.msg + + def render(self, request): + """render the error template for the exception""" + return render( request, - 'INVALID_REQUEST', - "you must specify and pgt and targetService" + "cas_server/samlValidateError.xml", + { + 'code':self.code, + 'msg':self.msg, + 'IssueInstant':timezone.now().isoformat(), + 'ResponseID':utils.gen_saml_id() + }, + content_type="text/xml; charset=utf-8" ) -def p3_service_validate(request): - """service ticket validation CAS 3.0""" - return service_validate(request) - -def p3_proxy_validate(request): - """service/proxy ticket validation CAS 3.0""" - return proxy_validate(request) - def _saml_validate_error(request, code, msg=""): """render the samlValidateError.xml templace using `code` and `msg`""" return render( @@ -475,76 +549,81 @@ def _saml_validate_error(request, code, msg=""): content_type="text/xml; charset=utf-8" ) -@csrf_exempt -def saml_validate(request): - """checks the validity of a Service Ticket by a SAML 1.1 request""" - if request.method == 'POST': - target = request.GET.get('TARGET') - root = etree.fromstring(request.body) +class SamlValidate(View, AttributesMixin): + """SAML ticket validation""" + request = None + target = None + ticket = None + root = None + + @method_decorator(csrf_exempt) + def dispatch(self, request, *args, **kwargs): + """dispatch requests based on method GET, POST, ...""" + return super(SamlValidation, self).dispatch(request, *args, **kwargs) + + def post(self, request): + """methode called on POST request on this view""" + self.request = request + self.target = request.GET.get('TARGET') + self.root = etree.fromstring(request.body) try: - auth_req = root.getchildren()[1].getchildren()[0] - issue_instant = auth_req.attrib['IssueInstant'] - request_id = auth_req.attrib['RequestID'] - ticket = auth_req.getchildren()[0].text - if ticket.startswith("ST"): - ticket = models.ServiceTicket.objects.get( - value=ticket, - validate=False, - creation__gt=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_VALIDITY)) - ) - elif ticket.startswith("PT"): - ticket = models.ProxyTicket.objects.get( - value=ticket, - validate=False, - creation__gt=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_VALIDITY)) - ) - else: - return _saml_validate_error( - request, - 'AuthnFailed', - 'ticket should begin with PT- or ST-' - ) - ticket.validate = True - ticket.save() - if ticket.service != target: - return _saml_validate_error( - request, - 'AuthnFailed', - 'TARGET do not match ticket service' - ) - expire_instant = (ticket.creation + \ + self.ticket = self.process_ticket() + expire_instant = (self.ticket.creation + \ timedelta(seconds=settings.CAS_TICKET_VALIDITY)).isoformat() - attributes = [] - for key, value in ticket.attributs.items(): - if isinstance(value, list): - for elt in value: - attributes.append((key, elt)) - else: - attributes.append((key, value)) + attributes = self.attributes() params = { - 'IssueInstant':issue_instant, + 'IssueInstant':timezone.now().isoformat(), 'expireInstant':expire_instant, - 'Recipient':target, + 'Recipient':self.target, 'ResponseID':utils.gen_saml_id(), - 'username':ticket.user.username, + 'username':self.ticket.user.username, 'attributes':attributes } - if ticket.service_pattern.user_field and \ - ticket.user.attributs.get(ticket.service_pattern.user_field): - params['username'] = ticket.user.attributs.get(ticket.service_pattern.user_field) + if self.ticket.service_pattern.user_field and \ + self.ticket.user.attributs.get(self.ticket.service_pattern.user_field): + params['username'] = self.ticket.user.attributs.get( + self.ticket.service_pattern.user_field + ) return render( request, "cas_server/samlValidate.xml", params, content_type="text/xml; charset=utf-8" ) + except SamlValidateError as error: + return error.render(request) + + def process_ticket(self): + """validate ticket from SAML XML body""" + try: + auth_req = self.root.getchildren()[1].getchildren()[0] + ticket = auth_req.getchildren()[0].text + if ticket.startswith(models.ServiceTicket.PREFIX): + ticket = models.ServiceTicket.objects.get( + value=ticket, + validate=False, + creation__gt=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_VALIDITY)) + ) + elif ticket.startswith(models.ProxyTicket.PREFIX): + ticket = models.ProxyTicket.objects.get( + value=ticket, + validate=False, + creation__gt=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_VALIDITY)) + ) + else: + raise SamlValidateError( + 'AuthnFailed', + 'ticket should begin with PT- or ST-' + ) + ticket.validate = True + ticket.save() + if ticket.service != self.target: + raise SamlValidateError( + 'AuthnFailed', + 'TARGET do not match ticket service' + ) + return ticket except (IndexError, KeyError): - return _saml_validate_error(request, 'VersionMismatch') + raise SamlValidateError('VersionMismatch') except (models.ServiceTicket.DoesNotExist, models.ProxyTicket.DoesNotExist): - return _saml_validate_error(request, 'AuthnFailed', 'ticket not found') - else: - return _saml_validate_error( - request, - 'VersionMismatch', - 'request should be send using POST' - ) + raise SamlValidateError('AuthnFailed', 'ticket not found')