From 6442040095f99a21e8c86471cfcbdf0879f9e000 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Tue, 2 Aug 2016 17:10:42 +0200 Subject: [PATCH 01/11] Add templatetags tests --- cas_server/tests/test_templatetags.py | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 cas_server/tests/test_templatetags.py diff --git a/cas_server/tests/test_templatetags.py b/cas_server/tests/test_templatetags.py new file mode 100644 index 0000000..989f939 --- /dev/null +++ b/cas_server/tests/test_templatetags.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License version 3 for +# more details. +# +# You should have received a copy of the GNU General Public License version 3 +# along with this program; if not, write to the Free Software Foundation, Inc., 51 +# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# (c) 2016 Valentin Samir +"""tests for the customs template tags""" +from django.test import TestCase + +from cas_server import forms +from cas_server.templatetags import cas_server + + +class TemplateTagsTestCase(TestCase): + """tests for the customs template tags""" + + def test_is_checkbox(self): + """test for the template filter is_checkbox""" + form = forms.UserCredential() + self.assertFalse(cas_server.is_checkbox(form["username"])) + self.assertTrue(cas_server.is_checkbox(form["warn"])) + + def test_is_hidden(self): + """test for the template filter is_hidden""" + form = forms.UserCredential() + self.assertFalse(cas_server.is_hidden(form["username"])) + self.assertTrue(cas_server.is_hidden(form["lt"])) From f4882c305718203f206c54cb3c0b0d274d74f1c8 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Tue, 2 Aug 2016 18:48:27 +0200 Subject: [PATCH 02/11] Add powered by footer, set warn cookie using javascript, w3c validation --- README.rst | 1 + cas_server/default_settings.py | 2 + cas_server/static/cas_server/alert-version.js | 27 ------------- cas_server/static/cas_server/functions.js | 40 +++++++++++++++++++ cas_server/static/cas_server/styles.css | 21 +++++++++- cas_server/templates/cas_server/base.html | 35 +++++++++++----- cas_server/templates/cas_server/form.html | 2 +- cas_server/templates/cas_server/login.html | 17 +++++--- 8 files changed, 100 insertions(+), 45 deletions(-) delete mode 100644 cas_server/static/cas_server/alert-version.js create mode 100644 cas_server/static/cas_server/functions.js diff --git a/README.rst b/README.rst index ba461e0..e134387 100644 --- a/README.rst +++ b/README.rst @@ -206,6 +206,7 @@ Template settings templates. Set it to ``False`` to disable it. * ``CAS_FAVICON_URL``: URL to the favicon (shortcut icon) used by the default templates. Default is a key icon. Set it to ``False`` to disable it. +* ``CAS_SHOW_POWERED``: Set it to ``False`` to hide the powered by footer. The default is ``True``. * ``CAS_COMPONENT_URLS``: URLs to css and javascript external components. It is a dictionnary and it must have the five following keys: ``"bootstrap3_css"``, ``"bootstrap3_js"``, ``"html5shiv"``, ``"respond"``, ``"jquery"``. The default is:: diff --git a/cas_server/default_settings.py b/cas_server/default_settings.py index bfa6a54..8474d0b 100644 --- a/cas_server/default_settings.py +++ b/cas_server/default_settings.py @@ -20,6 +20,8 @@ from importlib import import_module CAS_LOGO_URL = static("cas_server/logo.png") #: URL to the favicon (shortcut icon) used by the default templates. Default is a key icon. CAS_FAVICON_URL = static("cas_server/favicon.ico") +#: Show the powered by footer if set to ``True`` +CAS_SHOW_POWERED = True #: URLs to css and javascript external components. CAS_COMPONENT_URLS = { "bootstrap3_css": "//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css", diff --git a/cas_server/static/cas_server/alert-version.js b/cas_server/static/cas_server/alert-version.js deleted file mode 100644 index 7b4fef5..0000000 --- a/cas_server/static/cas_server/alert-version.js +++ /dev/null @@ -1,27 +0,0 @@ -function alert_version(last_version){ - jQuery(function( $ ){ - $("#alert-version").click(function( e ){ - e.preventDefault(); - var date = new Date(); - date.setTime(date.getTime()+(10*365*24*60*60*1000)); - var expires = "; expires="+date.toGMTString(); - document.cookie = "cas-alert-version=" + last_version + expires + "; path=/"; - }); - - var nameEQ="cas-alert-version="; - var ca = document.cookie.split(";"); - var value; - for(var i=0;i < ca.length;i++) { - var c = ca[i]; - while(c.charAt(0) === " "){ - c = c.substring(1,c.length); - } - if(c.indexOf(nameEQ) === 0){ - value = c.substring(nameEQ.length,c.length); - } - } - if(value === last_version){ - $("#alert-version").parent().hide(); - } - }); -} diff --git a/cas_server/static/cas_server/functions.js b/cas_server/static/cas_server/functions.js new file mode 100644 index 0000000..560c4fa --- /dev/null +++ b/cas_server/static/cas_server/functions.js @@ -0,0 +1,40 @@ +function createCookie(name, value, days) { + if (days) { + var date = new Date(); + date.setTime(date.getTime()+(days*24*60*60*1000)); + var expires = "; expires="+date.toGMTString(); + } + else var expires = ""; + document.cookie = name + "=" + value + expires + "; path=/"; +} + +function readCookie(name) { + var nameEQ = name + "="; + var ca = document.cookie.split(";"); + for(var i=0;i < ca.length;i++) { + var c = ca[i]; + while (c.charAt(0)==" "){ + c = c.substring(1,c.length); + } + if (c.indexOf(nameEQ) == 0){ + return c.substring(nameEQ.length,c.length); + } + } + return null; +} + +function eraseCookie(name) { + createCookie(name,"",-1); +} + +function alert_version(last_version){ + jQuery(function( $ ){ + $("#alert-version").click(function( e ){ + e.preventDefault(); + createCookie("cas-alert-version", last_version, 10*365) + }); + if(readCookie("cas-alert-version") === last_version){ + $("#alert-version").parent().hide(); + } + }); +} diff --git a/cas_server/static/cas_server/styles.css b/cas_server/static/cas_server/styles.css index 6d3524b..9ec8ec6 100644 --- a/cas_server/static/cas_server/styles.css +++ b/cas_server/static/cas_server/styles.css @@ -1,6 +1,9 @@ +html, body { + height: 100%; +} body { padding-top: 40px; - padding-bottom: 40px; + padding-bottom: 0px; background-color: #eee; } @@ -41,6 +44,22 @@ body { width:110px; } +/* Wrapper for page content to push down footer */ +#wrap { + min-height: 100%; + height: auto !important; + height: 100%; + /* Negative indent footer by it's height */ + margin: 0 auto -40px; +} +#footer { + height: 40px; + text-align: center; +} +#footer p { + padding-top: 10px; +} + @media screen and (max-width: 680px) { #app-name { margin: 0; diff --git a/cas_server/templates/cas_server/base.html b/cas_server/templates/cas_server/base.html index 87b9a6d..8a491ca 100644 --- a/cas_server/templates/cas_server/base.html +++ b/cas_server/templates/cas_server/base.html @@ -1,6 +1,4 @@ -{% load i18n %} -{% load staticfiles %} - +{% load i18n %}{% load staticfiles %} @@ -18,12 +16,13 @@ +
{% if auto_submit %}
{% endfor %} {% if auto_submit %}{% endif %} @@ -62,11 +61,25 @@
- - - {% if settings.CAS_NEW_VERSION_HTML_WARNING and upgrade_available %} - - - {% endif %} + +
+ {% if settings.CAS_SHOW_POWERED %} + + {% endif %} + + + + {% if settings.CAS_NEW_VERSION_HTML_WARNING and upgrade_available %} + + {% endif %} + {% block javascript %}{% endblock %} + diff --git a/cas_server/templates/cas_server/form.html b/cas_server/templates/cas_server/form.html index f189f6b..5571a7b 100644 --- a/cas_server/templates/cas_server/form.html +++ b/cas_server/templates/cas_server/form.html @@ -14,7 +14,7 @@ {% endif %}" {% endspaceless %}>{% spaceless %} {% if field|is_checkbox %} -
+
{% else %} {{field}} diff --git a/cas_server/templates/cas_server/login.html b/cas_server/templates/cas_server/login.html index d6adc64..ff98c03 100644 --- a/cas_server/templates/cas_server/login.html +++ b/cas_server/templates/cas_server/login.html @@ -14,10 +14,17 @@ {% if auto_submit %}{% endif %} -{% if auto_submit %} - -{% endif %} {% endblock %} +{% block javascript %}{% endblock %} From 170c557190d3b2e042eb6aea80439b8262698c37 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Wed, 3 Aug 2016 01:48:31 +0200 Subject: [PATCH 03/11] js/css style --- cas_server/static/cas_server/functions.js | 22 +++++++++++++--------- cas_server/static/cas_server/styles.css | 4 ++-- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/cas_server/static/cas_server/functions.js b/cas_server/static/cas_server/functions.js index 560c4fa..81a4add 100644 --- a/cas_server/static/cas_server/functions.js +++ b/cas_server/static/cas_server/functions.js @@ -1,22 +1,26 @@ -function createCookie(name, value, days) { - if (days) { - var date = new Date(); +function createCookie(name, value, days){ + var expires; + var date; + if(days){ + date = new Date(); date.setTime(date.getTime()+(days*24*60*60*1000)); - var expires = "; expires="+date.toGMTString(); + expires = "; expires="+date.toGMTString(); + } + else{ + expires = ""; } - else var expires = ""; document.cookie = name + "=" + value + expires + "; path=/"; } -function readCookie(name) { +function readCookie(name){ var nameEQ = name + "="; var ca = document.cookie.split(";"); for(var i=0;i < ca.length;i++) { var c = ca[i]; - while (c.charAt(0)==" "){ + while (c.charAt(0) === " "){ c = c.substring(1,c.length); } - if (c.indexOf(nameEQ) == 0){ + if (c.indexOf(nameEQ) === 0){ return c.substring(nameEQ.length,c.length); } } @@ -31,7 +35,7 @@ function alert_version(last_version){ jQuery(function( $ ){ $("#alert-version").click(function( e ){ e.preventDefault(); - createCookie("cas-alert-version", last_version, 10*365) + createCookie("cas-alert-version", last_version, 10*365); }); if(readCookie("cas-alert-version") === last_version){ $("#alert-version").parent().hide(); diff --git a/cas_server/static/cas_server/styles.css b/cas_server/static/cas_server/styles.css index 9ec8ec6..35b7c75 100644 --- a/cas_server/static/cas_server/styles.css +++ b/cas_server/static/cas_server/styles.css @@ -3,7 +3,7 @@ html, body { } body { padding-top: 40px; - padding-bottom: 0px; + padding-bottom: 0; background-color: #eee; } @@ -47,7 +47,7 @@ body { /* Wrapper for page content to push down footer */ #wrap { min-height: 100%; - height: auto !important; + height: auto; height: 100%; /* Negative indent footer by it's height */ margin: 0 auto -40px; From ee003b6b6563ea5066a4559121f499c3c08d7fdd Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Wed, 3 Aug 2016 01:50:45 +0200 Subject: [PATCH 04/11] Add a github version badge --- README.rst | 7 +++++-- setup.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index e134387..150def2 100644 --- a/README.rst +++ b/README.rst @@ -1,7 +1,7 @@ CAS Server ########## -|travis| |version| |lisence| |codacy| |coverage| |doc| +|travis| |coverage| |lisence| |github_version| |pypi_version| |codacy| |doc| CAS Server is a Django application implementing the `CAS Protocol 3.0 Specification `_. @@ -604,9 +604,12 @@ You could for example do as bellow : .. |travis| image:: https://badges.genua.fr/travis/nitmir/django-cas-server/master.svg :target: https://travis-ci.org/nitmir/django-cas-server -.. |version| image:: https://badges.genua.fr/pypi/v/django-cas-server.svg +.. |pypi_version| image:: https://badges.genua.fr/pypi/v/django-cas-server.svg :target: https://pypi.python.org/pypi/django-cas-server +.. |github_version| image:: https://badges.genua.fr/github/tag/nitmir/django-cas-server.svg?label=github + :target: https://github.com/nitmir/django-cas-server/releases/latest + .. |lisence| image:: https://badges.genua.fr/pypi/l/django-cas-server.svg :target: https://www.gnu.org/licenses/gpl-3.0.html diff --git a/setup.py b/setup.py index e33f30e..89c1a9c 100644 --- a/setup.py +++ b/setup.py @@ -62,7 +62,7 @@ if __name__ == '__main__': 'lxml >= 3.4', 'six >= 1' ], url="https://github.com/nitmir/django-cas-server", - download_url="https://github.com/nitmir/django-cas-server/releases", + download_url="https://github.com/nitmir/django-cas-server/releases/latest", zip_safe=False, setup_requires=['pytest-runner'], tests_require=['pytest', 'pytest-django', 'pytest-pythonpath', 'pytest-warnings', 'mock>=1'], From d46428520ffc1282d28d353ecae6a1ba49bc7464 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Fri, 5 Aug 2016 17:56:34 +0200 Subject: [PATCH 05/11] Code factorisation in models.py --- cas_server/models.py | 175 ++++++++++++++---------------- cas_server/tests/test_federate.py | 4 +- cas_server/tests/utils.py | 14 --- cas_server/utils.py | 20 ++++ 4 files changed, 102 insertions(+), 111 deletions(-) diff --git a/cas_server/models.py b/cas_server/models.py index dfedb1e..22aae24 100644 --- a/cas_server/models.py +++ b/cas_server/models.py @@ -28,13 +28,38 @@ from datetime import timedelta from concurrent.futures import ThreadPoolExecutor from requests_futures.sessions import FuturesSession -import cas_server.utils as utils +from cas_server import utils from . import VERSION #: logger facility logger = logging.getLogger(__name__) +class JsonAttributes(models.Model): + """ + Bases: :class:`django.db.models.Model` + + A base class for models storing attributes as a json + """ + + class Meta: + abstract = True + + #: The attributes json encoded + _attributs = models.TextField(default=None, null=True, blank=True) + + @property + def attributs(self): + """The attributes""" + if self._attributs is not None: + return utils.json.loads(self._attributs) + + @attributs.setter + def attributs(self, value): + """attributs property setter""" + self._attributs = utils.json_encode(value) + + @python_2_unicode_compatible class FederatedIendityProvider(models.Model): """ @@ -130,9 +155,9 @@ class FederatedIendityProvider(models.Model): @python_2_unicode_compatible -class FederatedUser(models.Model): +class FederatedUser(JsonAttributes): """ - Bases: :class:`django.db.models.Model` + Bases: :class:`JsonAttributes` A federated user as returner by a CAS provider (username and attributes) """ @@ -142,8 +167,6 @@ class FederatedUser(models.Model): username = models.CharField(max_length=124) #: A foreign key to :class:`FederatedIendityProvider` provider = models.ForeignKey(FederatedIendityProvider, on_delete=models.CASCADE) - #: The user attributes json encoded - _attributs = models.TextField(default=None, null=True, blank=True) #: The last ticket used to authenticate :attr:`username` against :attr:`provider` ticket = models.CharField(max_length=255) #: Last update timespampt. Usually, the last time :attr:`ticket` has been set. @@ -152,17 +175,6 @@ class FederatedUser(models.Model): def __str__(self): return self.federated_username - @property - def attributs(self): - """The user attributes returned by the CAS backend on successful ticket validation""" - if self._attributs is not None: - return utils.json.loads(self._attributs) - - @attributs.setter - def attributs(self, value): - """attributs property setter""" - self._attributs = utils.json_encode(value) - @property def federated_username(self): """The federated username with a suffix for the current :class:`FederatedUser`.""" @@ -290,35 +302,23 @@ class User(models.Model): :param request: The current django HttpRequest to display possible failure to the user. :type request: :class:`django.http.HttpRequest` or :obj:`NoneType` """ - async_list = [] - session = FuturesSession( - executor=ThreadPoolExecutor(max_workers=settings.CAS_SLO_MAX_PARALLEL_REQUESTS) - ) - # first invalidate all Tickets ticket_classes = [ProxyGrantingTicket, ServiceTicket, ProxyTicket] - for ticket_class in ticket_classes: - queryset = ticket_class.objects.filter(user=self) - for ticket in queryset: - ticket.logout(session, async_list) - queryset.delete() - for future in async_list: - if future: # pragma: no branch (should always be true) - try: - future.result() - except Exception as error: - logger.warning( - "Error during SLO for user %s: %s" % ( - self.username, - error - ) - ) - if request is not None: - error = utils.unpack_nested_exception(error) - messages.add_message( - request, - messages.WARNING, - _(u'Error during service logout %s') % error - ) + for error in Ticket.send_slos( + [ticket_class.objects.filter(user=self) for ticket_class in ticket_classes] + ): + logger.warning( + "Error during SLO for user %s: %s" % ( + self.username, + error + ) + ) + if request is not None: + error = utils.unpack_nested_exception(error) + messages.add_message( + request, + messages.WARNING, + _(u'Error during service logout %s') % error + ) def get_ticket(self, ticket_class, service, service_pattern, renew): """ @@ -544,20 +544,13 @@ class ServicePattern(models.Model): if re.match(filtre.pattern, str(value)): break else: + bad_filter = (filtre.pattern, filtre.attribut, user.attributs.get(filtre.attribut)) logger.warning( "User constraint failed for %s, service %s: %s do not match %s %s." % ( - user.username, - self.name, - filtre.pattern, - filtre.attribut, - user.attributs.get(filtre.attribut) + (user.username, self.name) + bad_filter ) ) - raise BadFilter('%s do not match %s %s' % ( - filtre.pattern, - filtre.attribut, - user.attributs.get(filtre.attribut) - )) + raise BadFilter('%s do not match %s %s' % bad_filter) if self.user_field and not user.attributs.get(self.user_field): logger.warning( "Cannot use %s a loggin for user %s on service %s because it is absent" % ( @@ -715,9 +708,9 @@ class ReplaceAttributValue(models.Model): @python_2_unicode_compatible -class Ticket(models.Model): +class Ticket(JsonAttributes): """ - Bases: :class:`django.db.models.Model` + Bases: :class:`JsonAttributes` Generic class for a Ticket """ @@ -725,8 +718,6 @@ class Ticket(models.Model): abstract = True #: ForeignKey to a :class:`User`. user = models.ForeignKey(User, related_name="%(class)s") - #: The user attributes to transmit to the service json encoded - _attributs = models.TextField(default=None, null=True, blank=True) #: A boolean. ``True`` if the ticket has been validated validate = models.BooleanField(default=False) #: The service url for the ticket @@ -749,17 +740,6 @@ class Ticket(models.Model): #: requests. TIMEOUT = settings.CAS_TICKET_TIMEOUT - @property - def attributs(self): - """The user attributes to be transmited to the service on successful validation""" - if self._attributs is not None: - return utils.json.loads(self._attributs) - - @attributs.setter - def attributs(self, value): - """attributs property setter""" - self._attributs = utils.json_encode(value) - class DoesNotExist(Exception): """raised in :meth:`Ticket.get` then ticket prefix and ticket classes mismatch""" pass @@ -767,6 +747,33 @@ class Ticket(models.Model): def __str__(self): return u"Ticket-%s" % self.pk + @staticmethod + def send_slos(queryset_list): + """ + Send SLO requests to each ticket of each queryset of ``queryset_list`` + + :param list queryset_list: A list a :class:`Ticket` queryset + :return: A list of possibly encoutered :class:`Exception` + :rtype: list + """ + # sending SLO to timed-out validated tickets + async_list = [] + session = FuturesSession( + executor=ThreadPoolExecutor(max_workers=settings.CAS_SLO_MAX_PARALLEL_REQUESTS) + ) + errors = [] + for queryset in queryset_list: + for ticket in queryset: + ticket.logout(session, async_list) + queryset.delete() + for future in async_list: + if future: # pragma: no branch (should always be true) + try: + future.result() + except Exception as error: + errors.append(error) + return errors + @classmethod def clean_old_entries(cls): """Remove old ticket and send SLO to timed-out services""" @@ -779,25 +786,12 @@ class Ticket(models.Model): Q(creation__lt=(timezone.now() - timedelta(seconds=cls.VALIDITY))) ) ).delete() - - # sending SLO to timed-out validated tickets - async_list = [] - session = FuturesSession( - executor=ThreadPoolExecutor(max_workers=settings.CAS_SLO_MAX_PARALLEL_REQUESTS) - ) queryset = cls.objects.filter( creation__lt=(timezone.now() - timedelta(seconds=cls.TIMEOUT)) ) - for ticket in queryset: - ticket.logout(session, async_list) - queryset.delete() - for future in async_list: - if future: # pragma: no branch (should always be true) - try: - future.result() - except Exception as error: - logger.warning("Error durring SLO %s" % error) - sys.stderr.write("%r\n" % error) + for error in cls.send_slos([queryset]): + logger.warning("Error durring SLO %s" % error) + sys.stderr.write("%r\n" % error) def logout(self, session, async_list=None): """Send a SLO request to the ticket service""" @@ -811,16 +805,7 @@ class Ticket(models.Model): self.user.username ) ) - xml = u""" - -%(ticket)s -""" % \ - { - 'id': utils.gen_saml_id(), - 'datetime': timezone.now().isoformat(), - 'ticket': self.value - } + xml = utils.logout_request(self.value) if self.service_pattern.single_log_out_callback: url = self.service_pattern.single_log_out_callback else: diff --git a/cas_server/tests/test_federate.py b/cas_server/tests/test_federate.py index cfcc5b7..2b389d3 100644 --- a/cas_server/tests/test_federate.py +++ b/cas_server/tests/test_federate.py @@ -261,7 +261,7 @@ class FederateAuthLoginLogoutTestCase( # SLO for an unkown ticket should do nothing response = client.post( "/federate/%s" % provider.suffix, - {'logoutRequest': tests_utils.logout_request(utils.gen_st())} + {'logoutRequest': utils.logout_request(utils.gen_st())} ) self.assertEqual(response.status_code, 200) self.assertEqual(response.content, b"ok") @@ -288,7 +288,7 @@ class FederateAuthLoginLogoutTestCase( # 3 or 'CAS_2_SAML_1_0' response = client.post( "/federate/%s" % provider.suffix, - {'logoutRequest': tests_utils.logout_request(ticket)} + {'logoutRequest': utils.logout_request(ticket)} ) self.assertEqual(response.status_code, 200) self.assertEqual(response.content, b"ok") diff --git a/cas_server/tests/utils.py b/cas_server/tests/utils.py index b13874c..bbab3cc 100644 --- a/cas_server/tests/utils.py +++ b/cas_server/tests/utils.py @@ -340,17 +340,3 @@ class DummyCAS(BaseHTTPServer.BaseHTTPRequestHandler): httpd_thread.daemon = True httpd_thread.start() return (httpd, host, port) - - -def logout_request(ticket): - """build a SLO request XML, ready to be send""" - return u""" - -%(ticket)s -""" % \ - { - 'id': utils.gen_saml_id(), - 'datetime': timezone.now().isoformat(), - 'ticket': ticket - } diff --git a/cas_server/utils.py b/cas_server/utils.py index c94ddf5..eb04a31 100644 --- a/cas_server/utils.py +++ b/cas_server/utils.py @@ -17,6 +17,7 @@ from django.http import HttpResponseRedirect, HttpResponse from django.contrib import messages from django.contrib.messages import constants as DEFAULT_MESSAGE_LEVELS from django.core.serializers.json import DjangoJSONEncoder +from django.utils import timezone import random import string @@ -680,3 +681,22 @@ def dictfetchall(cursor): dict(zip(columns, row)) for row in cursor.fetchall() ] + + +def logout_request(ticket): + """ + Forge a SLO logout request + + :param unicode ticket: A ticket value + :return: A SLO XML body request + :rtype: unicode + """ + return u""" + +%(ticket)s +""" % { + 'id': gen_saml_id(), + 'datetime': timezone.now().isoformat(), + 'ticket': ticket + } From aaadca15b77ac43b0fb5ae055a9fbd5dee27ffb0 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sat, 6 Aug 2016 11:09:15 +0200 Subject: [PATCH 06/11] Code factorisation in views.py --- cas_server/views.py | 170 ++++++++++++++++++++------------------------ 1 file changed, 78 insertions(+), 92 deletions(-) diff --git a/cas_server/views.py b/cas_server/views.py index e810f54..d7b78f0 100644 --- a/cas_server/views.py +++ b/cas_server/views.py @@ -45,6 +45,7 @@ logger = logging.getLogger(__name__) class LogoutMixin(object): """destroy CAS session utils""" + def logout(self, all_session=False): """ effectively destroy a CAS session @@ -63,43 +64,53 @@ class LogoutMixin(object): logger.info("Logging out user %s from all of they sessions." % username) else: logger.info("Logging out user %s." % username) - # logout the user from the current session + users = [] + # try to get the user from the current session try: - user = models.User.objects.get( - username=username, - session_key=self.request.session.session_key + users.append( + models.User.objects.get( + username=username, + session_key=self.request.session.session_key + ) ) - # flush the session + except models.User.DoesNotExist: + # if user not found in database, flush the session anyway self.request.session.flush() + + # If all_session is set, search all of the user sessions + if all_session: + users.extend(models.User.objects.filter(username=username)) + + # Iterate over all user sessions that have to be logged out + for user in users: + # get the user session + session = SessionStore(session_key=user.session_key) + # flush the session + session.flush() # send SLO requests user.logout(self.request) # delete the user user.delete() # increment the destroyed session counter session_nb += 1 - except models.User.DoesNotExist: - # if user not found in database, flush the session anyway - self.request.session.flush() - - # If all_session is set logout user from alternative sessions - if all_session: - # Iterate over all user sessions - for user in models.User.objects.filter(username=username): - # get the user session - session = SessionStore(session_key=user.session_key) - # flush the session - session.flush() - # send SLO requests - user.logout(self.request) - # delete the user - user.delete() - # increment the destroyed session counter - session_nb += 1 if username: logger.info("User %s logged out" % username) return session_nb +class CsrfExemptView(View): + """base class for csrf exempt class views""" + + @method_decorator(csrf_exempt) # csrf is disabled for allowing SLO requests reception + def dispatch(self, request, *args, **kwargs): + """ + dispatch different http request to the methods of the same name + + :param django.http.HttpRequest request: The current request object + """ + return super(CsrfExemptView, self).dispatch(request, *args, **kwargs) + + class LogoutView(View, LogoutMixin): """destroy CAS session (logout) view""" @@ -210,17 +221,12 @@ class LogoutView(View, LogoutMixin): ) -class FederateAuth(View): - """view to authenticated user agains a backend CAS then CAS_FEDERATE is True""" +class FederateAuth(CsrfExemptView): + """ + view to authenticated user agains a backend CAS then CAS_FEDERATE is True - @method_decorator(csrf_exempt) # csrf is disabled for allowing SLO requests reception - def dispatch(self, request, *args, **kwargs): - """ - dispatch different http request to the methods of the same name - - :param django.http.HttpRequest request: The current request object - """ - return super(FederateAuth, self).dispatch(request, *args, **kwargs) + csrf is disabled for allowing SLO requests reception. + """ def get_cas_client(self, request, provider, renew=False): """ @@ -923,18 +929,13 @@ class LoginView(View, LogoutMixin): return self.not_authenticated() -class Auth(View): - """A simple view to validate username/password/service tuple""" - # csrf is disable as it is intended to be used by programs. Security is assured by a shared - # secret between the programs dans django-cas-server. - @method_decorator(csrf_exempt) - def dispatch(self, request, *args, **kwargs): - """ - dispatch requests based on method GET, POST, ... +class Auth(CsrfExemptView): + """ + A simple view to validate username/password/service tuple - :param django.http.HttpRequest request: The current request object - """ - return super(Auth, self).dispatch(request, *args, **kwargs) + csrf is disable as it is intended to be used by programs. Security is assured by a shared + secret between the programs dans django-cas-server. + """ @staticmethod def post(request): @@ -1041,8 +1042,9 @@ class Validate(View): @python_2_unicode_compatible -class ValidateError(Exception): - """handle service validation error""" +class ValidationBaseError(Exception): + """Base class for both saml and cas validation error""" + #: The error code code = None #: The error message @@ -1064,12 +1066,23 @@ class ValidateError(Exception): :return: the rendered ``cas_server/serviceValidateError.xml`` template :rtype: django.http.HttpResponse """ - return render( - request, - "cas_server/serviceValidateError.xml", - {'code': self.code, 'msg': self.msg}, - content_type="text/xml; charset=utf-8" - ) + return render(request, self.template, self.contex(), content_type="text/xml; charset=utf-8") + + +class ValidateError(ValidationBaseError): + """handle service validation error""" + + #: template to be render for the error + template = "cas_server/serviceValidateError.xml" + + def context(self): + """ + content to use to render :attr:`template` + + :return: A dictionary to contextualize :attr:`template` + :rtype: dict + """ + return {'code': self.code, 'msg': self.msg} class ValidateService(View): @@ -1333,59 +1346,32 @@ class Proxy(View): ) -@python_2_unicode_compatible -class SamlValidateError(Exception): +class SamlValidateError(ValidationBaseError): """handle saml validation error""" - #: The error code - code = None - #: The error message - msg = None - def __init__(self, code, msg=""): - self.code = code - self.msg = msg - super(SamlValidateError, self).__init__(code) + #: template to be render for the error + template = "cas_server/samlValidateError.xml" - def __str__(self): - return u"%s" % self.msg - - def render(self, request): + def context(self): """ - render the error template for the exception - - :param django.http.HttpRequest request: The current request object: - :return: the rendered ``cas_server/samlValidateError.xml`` template - :rtype: django.http.HttpResponse + :return: A dictionary to contextualize :attr:`template` + :rtype: dict """ - return render( - request, - "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" - ) + return { + 'code': self.code, + 'msg': self.msg, + 'IssueInstant': timezone.now().isoformat(), + 'ResponseID': utils.gen_saml_id() + } -class SamlValidate(View): +class SamlValidate(CsrfExemptView): """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, ... - - :param django.http.HttpRequest request: The current request object - """ - return super(SamlValidate, self).dispatch(request, *args, **kwargs) - def post(self, request): """ methode called on POST request on this view From d0e92ed8f7f5132586de05514883b3b6118847c8 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sat, 6 Aug 2016 11:09:38 +0200 Subject: [PATCH 07/11] typo in README.rst --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 150def2..6c57a7f 100644 --- a/README.rst +++ b/README.rst @@ -1,7 +1,7 @@ CAS Server ########## -|travis| |coverage| |lisence| |github_version| |pypi_version| |codacy| |doc| +|travis| |coverage| |licence| |github_version| |pypi_version| |codacy| |doc| CAS Server is a Django application implementing the `CAS Protocol 3.0 Specification `_. @@ -610,7 +610,7 @@ You could for example do as bellow : .. |github_version| image:: https://badges.genua.fr/github/tag/nitmir/django-cas-server.svg?label=github :target: https://github.com/nitmir/django-cas-server/releases/latest -.. |lisence| image:: https://badges.genua.fr/pypi/l/django-cas-server.svg +.. |licence| image:: https://badges.genua.fr/pypi/l/django-cas-server.svg :target: https://www.gnu.org/licenses/gpl-3.0.html .. |codacy| image:: https://badges.genua.fr/codacy/grade/255c21623d6946ef8802fa7995b61366/master.svg From 29522aad4e7692aef881f785299618f171422efd Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sat, 6 Aug 2016 11:09:52 +0200 Subject: [PATCH 08/11] Usage of the documented API for models _meta --- cas_server/auth.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/cas_server/auth.py b/cas_server/auth.py index ab0c664..81dc37f 100644 --- a/cas_server/auth.py +++ b/cas_server/auth.py @@ -369,8 +369,16 @@ class DjangoAuthUser(AuthUser): # pragma: no cover """ if self.user: attr = {} - for field in self.user._meta.fields: - attr[field.attname] = getattr(self.user, field.attname) + # _meta.get_fields() is from the new documented _meta interface in django 1.8 + try: + field_names = [ + field.attname for field in self.user._meta.get_fields() if hasattr(field, "attname") + ] + # backward compatibility with django 1.7 + except AttributeError: # pragma: no cover (only used by django 1.7) + field_names = self.user._meta.get_all_field_names() + for name in field_names: + attr[name] = getattr(self.user, name) return attr else: return {} From ed5e3d5226a908adff3f83611156f7d253b778f3 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sat, 6 Aug 2016 11:31:39 +0200 Subject: [PATCH 09/11] Unfold many to many attributes in auth.DjangoAuthUser --- cas_server/auth.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/cas_server/auth.py b/cas_server/auth.py index 81dc37f..5f4913a 100644 --- a/cas_server/auth.py +++ b/cas_server/auth.py @@ -379,6 +379,23 @@ class DjangoAuthUser(AuthUser): # pragma: no cover field_names = self.user._meta.get_all_field_names() for name in field_names: attr[name] = getattr(self.user, name) + + # unfold user_permissions many to many relation + if 'user_permissions' in attr: + attr['user_permissions'] = [ + ( + u"%s.%s" % ( + perm.content_type.model_class().__module__, + perm.content_type.model_class().__name__ + ), + perm.codename + ) for perm in attr['user_permissions'].filter() + ] + + # unfold group many to many relation + if 'groups' in attr: + attr['groups'] = [group.name for group in attr['groups'].filter()] + return attr else: return {} From da796c6e0183b5f76764af520e786a16d68736c6 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sat, 6 Aug 2016 11:44:58 +0200 Subject: [PATCH 10/11] Corrects various errors spoted by travis and codacy --- cas_server/auth.py | 3 ++- cas_server/tests/utils.py | 8 ++++++-- cas_server/views.py | 21 +++++++++++++++++---- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/cas_server/auth.py b/cas_server/auth.py index 5f4913a..aa07593 100644 --- a/cas_server/auth.py +++ b/cas_server/auth.py @@ -372,7 +372,8 @@ class DjangoAuthUser(AuthUser): # pragma: no cover # _meta.get_fields() is from the new documented _meta interface in django 1.8 try: field_names = [ - field.attname for field in self.user._meta.get_fields() if hasattr(field, "attname") + field.attname for field in self.user._meta.get_fields() + if hasattr(field, "attname") ] # backward compatibility with django 1.7 except AttributeError: # pragma: no cover (only used by django 1.7) diff --git a/cas_server/tests/utils.py b/cas_server/tests/utils.py index bbab3cc..bee39cf 100644 --- a/cas_server/tests/utils.py +++ b/cas_server/tests/utils.py @@ -115,8 +115,8 @@ def get_validated_ticket(service): client = Client() response = client.get('/validate', {'ticket': ticket.value, 'service': service}) - assert (response.status_code == 200) - assert (response.content == b'yes\ntest\n') + assert response.status_code == 200 + assert response.content == b'yes\ntest\n' ticket = models.ServiceTicket.objects.get(value=ticket.value) return (auth_client, ticket) @@ -222,6 +222,10 @@ class Http404Handler(HttpParamsHandler): class DummyCAS(BaseHTTPServer.BaseHTTPRequestHandler): """A dummy CAS that validate for only one (service, ticket) used in federated mode tests""" + + #: dict of the last receive GET parameters + params = None + def test_params(self): """check that internal and provided (service, ticket) matches""" if ( diff --git a/cas_server/views.py b/cas_server/views.py index d7b78f0..f9be770 100644 --- a/cas_server/views.py +++ b/cas_server/views.py @@ -79,7 +79,13 @@ class LogoutMixin(object): # If all_session is set, search all of the user sessions if all_session: - users.extend(models.User.objects.filter(username=username)) + users.extend( + models.User.objects.filter( + username=username + ).exclude( + session_key=self.request.session.session_key + ) + ) # Iterate over all user sessions that have to be logged out for user in users: @@ -228,6 +234,9 @@ class FederateAuth(CsrfExemptView): csrf is disabled for allowing SLO requests reception. """ + #: current URL used as service URL by the CAS client + service_url = None + def get_cas_client(self, request, provider, renew=False): """ return a CAS client object matching provider @@ -291,7 +300,7 @@ class FederateAuth(CsrfExemptView): """ method called on GET request - :param django.http.HttpRequest request: The current request object + :param django.http.HttpRequestself. request: The current request object :param unicode provider: Optional parameter. The user provider suffix. """ # if settings.CAS_FEDERATE is not True redirect to the login page @@ -1053,7 +1062,7 @@ class ValidationBaseError(Exception): def __init__(self, code, msg=""): self.code = code self.msg = msg - super(ValidateError, self).__init__(code) + super(ValidationBaseError, self).__init__(code) def __str__(self): return u"%s" % self.msg @@ -1066,7 +1075,11 @@ class ValidationBaseError(Exception): :return: the rendered ``cas_server/serviceValidateError.xml`` template :rtype: django.http.HttpResponse """ - return render(request, self.template, self.contex(), content_type="text/xml; charset=utf-8") + return render( + request, + self.template, + self.context(), content_type="text/xml; charset=utf-8" + ) class ValidateError(ValidationBaseError): From 84d0d267ad34fff117f74bdec31ee52dd699b2ae Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sat, 13 Aug 2016 19:39:47 +0200 Subject: [PATCH 11/11] Update version to 0.6.3 --- cas_server/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cas_server/__init__.py b/cas_server/__init__.py index 8d6dd5d..0aac300 100644 --- a/cas_server/__init__.py +++ b/cas_server/__init__.py @@ -11,7 +11,7 @@ """A django CAS server application""" #: version of the application -VERSION = '0.6.2' +VERSION = '0.6.3' #: path the the application configuration class default_app_config = 'cas_server.apps.CasAppConfig'