From efdd97ec07638b5139211a4049557e85eed7080c Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Fri, 17 Jun 2016 19:28:49 +0200 Subject: [PATCH 01/34] Test for CAS federation --- .gitignore | 1 + cas_server/auth.py | 39 ++ cas_server/cas.py | 337 ++++++++++++++++++ cas_server/default_settings.py | 12 + cas_server/federate.py | 69 ++++ cas_server/forms.py | 38 +- .../migrations/0005_auto_20160616_1018.py | 31 ++ cas_server/models.py | 10 + cas_server/templates/cas_server/federate.html | 22 ++ cas_server/templates/cas_server/login.html | 11 +- cas_server/urls.py | 1 + cas_server/utils.py | 39 +- cas_server/views.py | 124 ++++++- 13 files changed, 721 insertions(+), 13 deletions(-) create mode 100644 cas_server/cas.py create mode 100644 cas_server/federate.py create mode 100644 cas_server/migrations/0005_auto_20160616_1018.py create mode 100644 cas_server/templates/cas_server/federate.html diff --git a/.gitignore b/.gitignore index 0b5a2a6..2ba2ee7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.pyc *.egg-info +*.swp build/ bootstrap3 diff --git a/cas_server/auth.py b/cas_server/auth.py index 7ccacae..99018a4 100644 --- a/cas_server/auth.py +++ b/cas_server/auth.py @@ -12,6 +12,9 @@ """Some authentication classes for the CAS""" from django.conf import settings from django.contrib.auth import get_user_model +from django.utils import timezone + +from datetime import timedelta try: import MySQLdb import MySQLdb.cursors @@ -19,6 +22,8 @@ try: except ImportError: MySQLdb = None +from .models import FederatedUser + class AuthUser(object): def __init__(self, username): @@ -140,3 +145,37 @@ class DjangoAuthUser(AuthUser): for field in self.user._meta.fields: attr[field.attname] = getattr(self.user, field.attname) return attr + + +class CASFederateAuth(AuthUser): + user = None + + def __init__(self, username): + component = username.split('@') + username = '@'.join(component[:-1]) + provider = component[-1] + try: + self.user = FederatedUser.objects.get(username=username, provider=provider) + super(CASFederateAuth, self).__init__( + "%s@%s" % (self.user.username, self.user.provider) + ) + except FederatedUser.DoesNotExist: + super(CASFederateAuth, self).__init__("%s@%s" % (username, provider)) + + def test_password(self, ticket): + """test `password` agains the user""" + if not self.user or not self.user.ticket: + return False + else: + return ( + ticket == self.user.ticket and + self.user.last_update > + (timezone.now() - timedelta(seconds=settings.CAS_TICKET_VALIDITY)) + ) + + def attributs(self): + """return a dict of user attributes""" + if not self.user: + return {} + else: + return self.user.attributs diff --git a/cas_server/cas.py b/cas_server/cas.py new file mode 100644 index 0000000..bea0638 --- /dev/null +++ b/cas_server/cas.py @@ -0,0 +1,337 @@ +from six.moves.urllib import parse as urllib_parse +from six.moves.urllib import request as urllib_request +from six.moves.urllib.request import Request +from uuid import uuid4 +import datetime + + +class CASError(ValueError): + pass + + +class SingleLogoutMixin(object): + @classmethod + def get_saml_slos(cls, logout_request): + """returns saml logout ticket info""" + from lxml import etree + try: + root = etree.fromstring(logout_request) + return root.xpath( + "//samlp:SessionIndex", + namespaces={'samlp': "urn:oasis:names:tc:SAML:2.0:protocol"}) + except etree.XMLSyntaxError: + pass + + +class CASClient(object): + def __new__(self, *args, **kwargs): + version = kwargs.pop('version') + if version in (1, '1'): + return CASClientV1(*args, **kwargs) + elif version in (2, '2'): + return CASClientV2(*args, **kwargs) + elif version in (3, '3'): + return CASClientV3(*args, **kwargs) + elif version == 'CAS_2_SAML_1_0': + return CASClientWithSAMLV1(*args, **kwargs) + raise ValueError('Unsupported CAS_VERSION %r' % version) + + +class CASClientBase(object): + + logout_redirect_param_name = 'service' + + def __init__(self, service_url=None, server_url=None, + extra_login_params=None, renew=False, + username_attribute=None): + + self.service_url = service_url + self.server_url = server_url + self.extra_login_params = extra_login_params or {} + self.renew = renew + self.username_attribute = username_attribute + pass + + def verify_ticket(self, ticket): + """must return a triple""" + raise NotImplementedError() + + def get_login_url(self): + """Generates CAS login URL""" + params = {'service': self.service_url} + if self.renew: + params.update({'renew': 'true'}) + + params.update(self.extra_login_params) + url = urllib_parse.urljoin(self.server_url, 'login') + query = urllib_parse.urlencode(params) + return url + '?' + query + + def get_logout_url(self, redirect_url=None): + """Generates CAS logout URL""" + url = urllib_parse.urljoin(self.server_url, 'logout') + if redirect_url: + params = {self.logout_redirect_param_name: redirect_url} + url += '?' + urllib_parse.urlencode(params) + return url + + def get_proxy_url(self, pgt): + """Returns proxy url, given the proxy granting ticket""" + params = urllib_parse.urlencode({'pgt': pgt, 'targetService': self.service_url}) + return "%s/proxy?%s" % (self.server_url, params) + + def get_proxy_ticket(self, pgt): + """Returns proxy ticket given the proxy granting ticket""" + response = urllib_request.urlopen(self.get_proxy_url(pgt)) + if response.code == 200: + from lxml import etree + root = etree.fromstring(response.read()) + tickets = root.xpath( + "//cas:proxyTicket", + namespaces={"cas": "http://www.yale.edu/tp/cas"} + ) + if len(tickets) == 1: + return tickets[0].text + errors = root.xpath( + "//cas:authenticationFailure", + namespaces={"cas": "http://www.yale.edu/tp/cas"} + ) + if len(errors) == 1: + raise CASError(errors[0].attrib['code'], errors[0].text) + raise CASError("Bad http code %s" % response.code) + + +class CASClientV1(CASClientBase): + """CAS Client Version 1""" + + logout_redirect_param_name = 'url' + + def verify_ticket(self, ticket): + """Verifies CAS 1.0 authentication ticket. + + Returns username on success and None on failure. + """ + params = [('ticket', ticket), ('service', self.service)] + url = (urllib_parse.urljoin(self.server_url, 'validate') + '?' + + urllib_parse.urlencode(params)) + page = urllib_request.urlopen(url) + try: + verified = page.readline().strip() + if verified == 'yes': + return page.readline().strip(), None, None + else: + return None, None, None + finally: + page.close() + + +class CASClientV2(CASClientBase): + """CAS Client Version 2""" + + url_suffix = 'serviceValidate' + logout_redirect_param_name = 'url' + + def __init__(self, proxy_callback=None, *args, **kwargs): + """proxy_callback is for V2 and V3 so V3 is subclass of V2""" + self.proxy_callback = proxy_callback + super(CASClientV2, self).__init__(*args, **kwargs) + + def verify_ticket(self, ticket): + """Verifies CAS 2.0+/3.0+ XML-based authentication ticket and returns extended attributes""" + response = self.get_verification_response(ticket) + return self.verify_response(response) + + def get_verification_response(self, ticket): + params = [('ticket', ticket), ('service', self.service_url)] + if self.proxy_callback: + params.append(('pgtUrl', self.proxy_callback)) + base_url = urllib_parse.urljoin(self.server_url, self.url_suffix) + url = base_url + '?' + urllib_parse.urlencode(params) + page = urllib_request.urlopen(url) + try: + return page.read() + finally: + page.close() + + @classmethod + def parse_attributes_xml_element(cls, element): + attributes = dict() + for attribute in element: + tag = attribute.tag.split("}").pop() + if tag in attributes: + if isinstance(attributes[tag], list): + attributes[tag].append(attribute.text) + else: + attributes[tag] = [attributes[tag]] + attributes[tag].append(attribute.text) + else: + if tag == 'attraStyle': + pass + else: + attributes[tag] = attribute.text + return attributes + + @classmethod + def verify_response(cls, response): + user, attributes, pgtiou = cls.parse_response_xml(response) + if len(attributes) == 0: + attributes = None + return user, attributes, pgtiou + + @classmethod + def parse_response_xml(cls, response): + try: + from xml.etree import ElementTree + except ImportError: + from elementtree import ElementTree + + user = None + attributes = {} + pgtiou = None + + tree = ElementTree.fromstring(response) + if tree[0].tag.endswith('authenticationSuccess'): + for element in tree[0]: + if element.tag.endswith('user'): + user = element.text + elif element.tag.endswith('proxyGrantingTicket'): + pgtiou = element.text + elif element.tag.endswith('attributes'): + attributes = cls.parse_attributes_xml_element(element) + return user, attributes, pgtiou + + +class CASClientV3(CASClientV2, SingleLogoutMixin): + """CAS Client Version 3""" + url_suffix = 'serviceValidate' + logout_redirect_param_name = 'service' + + @classmethod + def parse_attributes_xml_element(cls, element): + attributes = dict() + for attribute in element: + tag = attribute.tag.split("}").pop() + if tag in attributes: + if isinstance(attributes[tag], list): + attributes[tag].append(attribute.text) + else: + attributes[tag] = [attributes[tag]] + attributes[tag].append(attribute.text) + else: + attributes[tag] = attribute.text + return attributes + + @classmethod + def verify_response(cls, response): + return cls.parse_response_xml(response) + + +SAML_1_0_NS = 'urn:oasis:names:tc:SAML:1.0:' +SAML_1_0_PROTOCOL_NS = '{' + SAML_1_0_NS + 'protocol' + '}' +SAML_1_0_ASSERTION_NS = '{' + SAML_1_0_NS + 'assertion' + '}' +SAML_ASSERTION_TEMPLATE = """ + + + + +{ticket} + +""" + + +class CASClientWithSAMLV1(CASClientV2, SingleLogoutMixin): + """CASClient 3.0+ with SAML""" + + def verify_ticket(self, ticket, **kwargs): + """Verifies CAS 3.0+ XML-based authentication ticket and returns extended attributes. + + @date: 2011-11-30 + @author: Carlos Gonzalez Vila + + Returns username and attributes on success and None,None on failure. + """ + + try: + from xml.etree import ElementTree + except ImportError: + from elementtree import ElementTree + + page = self.fetch_saml_validation(ticket) + + try: + user = None + attributes = {} + response = page.read() + tree = ElementTree.fromstring(response) + # Find the authentication status + success = tree.find('.//' + SAML_1_0_PROTOCOL_NS + 'StatusCode') + if success is not None and success.attrib['Value'].endswith(':Success'): + # User is validated + attrs = tree.findall('.//' + SAML_1_0_ASSERTION_NS + 'Attribute') + for at in attrs: + if self.username_attribute in list(at.attrib.values()): + user = at.find(SAML_1_0_ASSERTION_NS + 'AttributeValue').text + attributes['uid'] = user + + values = at.findall(SAML_1_0_ASSERTION_NS + 'AttributeValue') + if len(values) > 1: + values_array = [] + for v in values: + values_array.append(v.text) + attributes[at.attrib['AttributeName']] = values_array + else: + attributes[at.attrib['AttributeName']] = values[0].text + return user, attributes, None + finally: + page.close() + + def fetch_saml_validation(self, ticket): + # We do the SAML validation + headers = { + 'soapaction': 'http://www.oasis-open.org/committees/security', + 'cache-control': 'no-cache', + 'pragma': 'no-cache', + 'accept': 'text/xml', + 'connection': 'keep-alive', + 'content-type': 'text/xml; charset=utf-8', + } + params = [('TARGET', self.service_url)] + saml_validate_url = urllib_parse.urljoin( + self.server_url, 'samlValidate', + ) + request = Request( + saml_validate_url + '?' + urllib_parse.urlencode(params), + self.get_saml_assertion(ticket), + headers, + ) + return urllib_request.urlopen(request) + + @classmethod + def get_saml_assertion(cls, ticket): + """ + http://www.jasig.org/cas/protocol#samlvalidate-cas-3.0 + + SAML request values: + + RequestID [REQUIRED]: + unique identifier for the request + IssueInstant [REQUIRED]: + timestamp of the request + samlp:AssertionArtifact [REQUIRED]: + the valid CAS Service Ticket obtained as a response parameter at login. + """ + # RequestID [REQUIRED] - unique identifier for the request + request_id = uuid4() + + # e.g. 2014-06-02T09:21:03.071189 + timestamp = datetime.datetime.now().isoformat() + + return SAML_ASSERTION_TEMPLATE.format( + request_id=request_id, + timestamp=timestamp, + ticket=ticket, + ).encode('utf8') diff --git a/cas_server/default_settings.py b/cas_server/default_settings.py index 139569d..fe5de28 100644 --- a/cas_server/default_settings.py +++ b/cas_server/default_settings.py @@ -18,6 +18,7 @@ def setting_default(name, default_value): setattr(settings, name, value) setting_default('CAS_LOGIN_TEMPLATE', 'cas_server/login.html') +setting_default('CAS_FEDERATE_TEMPLATE', 'cas_server/federate.html') setting_default('CAS_WARN_TEMPLATE', 'cas_server/warn.html') setting_default('CAS_LOGGED_TEMPLATE', 'cas_server/logged.html') setting_default('CAS_LOGOUT_TEMPLATE', 'cas_server/logout.html') @@ -70,3 +71,14 @@ setting_default('CAS_SQL_DBCHARSET', 'utf8') 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 + + +setting_default('CAS_FEDERATE', False) +# A dict of "provider name" -> (provider CAS server url, CAS version) +setting_default('CAS_FEDERATE_PROVIDERS', {}) + +if settings.CAS_FEDERATE: + settings.CAS_AUTH_CLASS = "cas_server.auth.CASFederateAuth" + +CAS_FEDERATE_PROVIDERS_LIST = settings.CAS_FEDERATE_PROVIDERS.keys() +CAS_FEDERATE_PROVIDERS_LIST.sort() diff --git a/cas_server/federate.py b/cas_server/federate.py new file mode 100644 index 0000000..529ddd1 --- /dev/null +++ b/cas_server/federate.py @@ -0,0 +1,69 @@ +# ⁻*- 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) 2015 Valentin Samir +from .default_settings import settings + +from .cas import CASClient +from .models import FederatedUser + + +class CASFederateValidateUser(object): + username = None + attributs = {} + client = None + + def __init__(self, provider, service_url): + self.provider = provider + + if provider in settings.CAS_FEDERATE_PROVIDERS: + (server_url, version) = settings.CAS_FEDERATE_PROVIDERS[provider] + self.client = CASClient( + service_url=service_url, + version=version, + server_url=server_url, + extra_login_params={"provider": provider}, + renew=False, + ) + + def get_login_url(self): + return self.client.get_login_url() if self.client is not None else False + + def get_logout_url(self, redirect_url=None): + return self.client.get_logout_url(redirect_url) if self.client is not None else False + + def verify_ticket(self, ticket): + """test `password` agains the user""" + if self.client is None: + return False + username, attributs, pgtiou = self.client.verify_ticket(ticket) + if username is not None: + attributs["provider"] = self.provider + self.username = username + self.attributs = attributs + try: + user = FederatedUser.objects.get( + username=username, + provider=self.provider + ) + user.attributs = attributs + user.ticket = ticket + user.save() + except FederatedUser.DoesNotExist: + user = FederatedUser.objects.create( + username=username, + provider=self.provider, + attributs=attributs, + ticket=ticket + ) + user.save() + return True + else: + return False diff --git a/cas_server/forms.py b/cas_server/forms.py index f970ccd..33b3a2c 100644 --- a/cas_server/forms.py +++ b/cas_server/forms.py @@ -9,7 +9,7 @@ # # (c) 2015 Valentin Samir """forms for the app""" -from .default_settings import settings +from .default_settings import settings, CAS_FEDERATE_PROVIDERS_LIST from django import forms from django.utils.translation import ugettext_lazy as _ @@ -27,6 +27,17 @@ class WarnForm(forms.Form): lt = forms.CharField(widget=forms.HiddenInput(), required=False) +class FederateSelect(forms.Form): + provider = forms.ChoiceField( + label=_('Identity provider'), + choices=[(p, p) for p in CAS_FEDERATE_PROVIDERS_LIST] + ) + service = forms.CharField(label=_('service'), widget=forms.HiddenInput(), required=False) + method = forms.CharField(widget=forms.HiddenInput(), required=False) + remember = forms.BooleanField(label=_('Remember the identity provider'), required=False) + warn = forms.BooleanField(label=_('warn'), required=False) + + class UserCredential(forms.Form): """Form used on the login page to retrive user credentials""" username = forms.CharField(label=_('login')) @@ -46,6 +57,31 @@ class UserCredential(forms.Form): cleaned_data["username"] = auth.username else: raise forms.ValidationError(_(u"Bad user")) + return cleaned_data + + +class FederateUserCredential(UserCredential): + """Form used on the login page to retrive user credentials""" + username = forms.CharField(widget=forms.HiddenInput()) + service = forms.CharField(widget=forms.HiddenInput(), required=False) + password = forms.CharField(widget=forms.HiddenInput()) + ticket = forms.CharField(widget=forms.HiddenInput()) + lt = forms.CharField(widget=forms.HiddenInput(), required=False) + method = forms.CharField(widget=forms.HiddenInput(), required=False) + warn = forms.BooleanField(widget=forms.HiddenInput(), required=False) + + def clean(self): + cleaned_data = super(FederateUserCredential, self).clean() + try: + component = cleaned_data["username"].split('@') + username = '@'.join(component[:-1]) + provider = component[-1] + user = models.FederatedUser.objects.get(username=username, provider=provider) + user.ticket = "" + user.save() + except models.FederatedUser.DoesNotExist: + raise + return cleaned_data class TicketForm(forms.ModelForm): diff --git a/cas_server/migrations/0005_auto_20160616_1018.py b/cas_server/migrations/0005_auto_20160616_1018.py new file mode 100644 index 0000000..4a503ea --- /dev/null +++ b/cas_server/migrations/0005_auto_20160616_1018.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.6 on 2016-06-16 10:18 +from __future__ import unicode_literals + +from django.db import migrations, models +import picklefield.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('cas_server', '0004_auto_20151218_1032'), + ] + + operations = [ + migrations.CreateModel( + name='FederatedUser', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('username', models.CharField(max_length=124)), + ('provider', models.CharField(max_length=124)), + ('attributs', picklefield.fields.PickledObjectField(editable=False)), + ('ticket', models.CharField(max_length=255)), + ('last_update', models.DateTimeField(auto_now=True)), + ], + ), + migrations.AlterUniqueTogether( + name='federateduser', + unique_together=set([('username', 'provider')]), + ), + ] diff --git a/cas_server/models.py b/cas_server/models.py index 9cb0ac5..746e7e6 100644 --- a/cas_server/models.py +++ b/cas_server/models.py @@ -35,6 +35,16 @@ SessionStore = import_module(settings.SESSION_ENGINE).SessionStore logger = logging.getLogger(__name__) +class FederatedUser(models.Model): + class Meta: + unique_together = ("username", "provider") + username = models.CharField(max_length=124) + provider = models.CharField(max_length=124) + attributs = PickledObjectField() + ticket = models.CharField(max_length=255) + last_update = models.DateTimeField(auto_now=True) + + class User(models.Model): """A user logged into the CAS""" class Meta: diff --git a/cas_server/templates/cas_server/federate.html b/cas_server/templates/cas_server/federate.html new file mode 100644 index 0000000..1411513 --- /dev/null +++ b/cas_server/templates/cas_server/federate.html @@ -0,0 +1,22 @@ +{% extends "cas_server/base.html" %} +{% load bootstrap3 %} +{% load staticfiles %} +{% load i18n %} +{% block content %} + +{% if auto_submit %} + +{% endif %} +{% endblock %} + diff --git a/cas_server/templates/cas_server/login.html b/cas_server/templates/cas_server/login.html index b423797..d4559fe 100644 --- a/cas_server/templates/cas_server/login.html +++ b/cas_server/templates/cas_server/login.html @@ -3,11 +3,20 @@ {% load staticfiles %} {% load i18n %} {% block content %} - +{% if auto_submit %} + +{% endif %} {% endblock %} diff --git a/cas_server/urls.py b/cas_server/urls.py index b2ed38b..2a87ef4 100644 --- a/cas_server/urls.py +++ b/cas_server/urls.py @@ -59,4 +59,5 @@ urlpatterns = patterns( ), name='auth' ), + url("^federate(?:/(?P([^/]+)))?$", views.FederateAuth.as_view(), name='federateAuth'), ) diff --git a/cas_server/utils.py b/cas_server/utils.py index c3b2c32..ee6f1c4 100644 --- a/cas_server/utils.py +++ b/cas_server/utils.py @@ -20,6 +20,7 @@ import random import string import json from importlib import import_module +from datetime import datetime, timedelta try: from urlparse import urlparse, urlunparse, parse_qsl @@ -60,7 +61,43 @@ def redirect_params(url_name, params=None): def reverse_params(url_name, params=None, **kwargs): url = reverse(url_name, **kwargs) params = urlencode(params if params else {}) - return url + "?%s" % params + if params: + return url + "?%s" % params + else: + return url + + +def copy_params(get_or_post_params, ignore=set()): + params = {} + for key in get_or_post_params: + if key not in ignore and get_or_post_params[key]: + params[key] = get_or_post_params[key] + return params + + +def set_cookie(response, key, value, max_age): + expires = datetime.strftime( + datetime.utcnow() + timedelta(seconds=max_age), + "%a, %d-%b-%Y %H:%M:%S GMT" + ) + response.set_cookie( + key, + value, + max_age=max_age, + expires=expires, + domain=settings.SESSION_COOKIE_DOMAIN, + secure=settings.SESSION_COOKIE_SECURE or None + ) + + +def get_current_url(request, ignore_params=set()): + protocol = 'https' if request.is_secure() else "http" + service_url = "%s://%s%s" % (protocol, request.get_host(), request.path) + if request.GET: + params = copy_params(request.GET, ignore_params) + if params: + service_url += "?%s" % urlencode(params) + return service_url def update_url(url, params): diff --git a/cas_server/views.py b/cas_server/views.py index 4e27ead..733c53c 100644 --- a/cas_server/views.py +++ b/cas_server/views.py @@ -37,6 +37,7 @@ import cas_server.models as models from .utils import JsonResponse from .models import ServiceTicket, ProxyTicket, ProxyGrantingTicket from .models import ServicePattern +from .federate import CASFederateValidateUser SessionStore = import_module(settings.SESSION_ENGINE).SessionStore @@ -113,7 +114,18 @@ class LogoutView(View, LogoutMixin): """methode called on GET request on this view""" logger.info("logout requested") self.init_get(request) + # if CAS federation mode is enable, bakup the provider before flushing the sessions + if settings.CAS_FEDERATE: + component = self.request.session.get("username").split('@') + provider = component[-1] + auth = CASFederateValidateUser(provider, service_url="") session_nb = self.logout(self.request.GET.get("all")) + # if CAS federation mode is enable, redirect to user CAS logout page + if settings.CAS_FEDERATE: + params = utils.copy_params(request.GET) + url = utils.update_url(auth.get_logout_url(), params) + if url: + return HttpResponseRedirect(url) # if service is set, redirect to service after logout if self.service: list(messages.get_messages(request)) # clean messages before leaving the django app @@ -168,6 +180,45 @@ class LogoutView(View, LogoutMixin): ) +class FederateAuth(View): + def post(self, request, provider=None): + form = forms.FederateSelect(request.POST) + if form.is_valid(): + params = utils.copy_params( + request.POST, + ignore={"provider", "csrfmiddlewaretoken", "ticket"} + ) + url = utils.reverse_params( + "cas_server:federateAuth", + kwargs=dict(provider=form.cleaned_data["provider"]), + params=params + ) + response = HttpResponseRedirect(url) + if form.cleaned_data["remember"]: + max_age = 7 * 24 * 60 * 60 # one week + utils.set_cookie(response, "_remember_provider", request.POST["provider"], max_age) + return response + else: + return redirect("cas_server:login") + + def get(self, request, provider=None): + if provider not in settings.CAS_FEDERATE_PROVIDERS: + return redirect("cas_server:login") + service_url = utils.get_current_url(request, {"ticket", "provider"}) + auth = CASFederateValidateUser(provider, service_url) + if 'ticket' not in request.GET: + return HttpResponseRedirect(auth.get_login_url()) + else: + ticket = request.GET['ticket'] + if auth.verify_ticket(ticket): + params = utils.copy_params(request.GET) + params['username'] = "%s@%s" % (auth.username, auth.provider) + url = utils.reverse_params("cas_server:login", params) + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(auth.get_login_url()) + + class LoginView(View, LogoutMixin): """credential requestor / acceptor""" @@ -206,6 +257,10 @@ class LoginView(View, LogoutMixin): self.ajax = 'HTTP_X_AJAX' in request.META if request.POST.get('warned') and request.POST['warned'] != "False": self.warned = True + self.warn = request.POST.get('warn') + if settings.CAS_FEDERATE: + self.username = request.POST.get('username') + self.ticket = request.POST.get('ticket') def check_lt(self): # save LT for later check @@ -248,6 +303,7 @@ class LoginView(View, LogoutMixin): ) self.user.save() elif ret == self.USER_LOGIN_FAILURE: # bad user login + self.ticket = None self.logout() elif ret == self.USER_ALREADY_LOGGED: pass @@ -291,6 +347,10 @@ class LoginView(View, LogoutMixin): self.gateway = request.GET.get('gateway') self.method = request.GET.get('method') self.ajax = 'HTTP_X_AJAX' in request.META + self.warn = request.GET.get('warn') + if settings.CAS_FEDERATE: + self.username = request.GET.get('username') + self.ticket = request.GET.get('ticket') def get(self, request, *args, **kwargs): """methode called on GET request on this view""" @@ -308,15 +368,28 @@ class LoginView(View, LogoutMixin): return self.USER_AUTHENTICATED def init_form(self, values=None): - self.form = forms.UserCredential( - values, - initial={ - 'service': self.service, - 'method': self.method, - 'warn': self.request.session.get("warn"), - 'lt': self.request.session['lt'][-1] - } - ) + form_initial = { + 'service': self.service, + 'method': self.method, + 'warn': self.warn or self.request.session.get("warn"), + 'lt': self.request.session['lt'][-1] + } + if settings.CAS_FEDERATE: + if self.username and self.ticket: + form_initial['username'] = self.username + form_initial['password'] = self.ticket + form_initial['ticket'] = self.ticket + self.form = forms.FederateUserCredential( + values, + initial=form_initial + ) + else: + self.form = forms.FederateSelect(values, initial=form_initial) + else: + self.form = forms.UserCredential( + values, + initial=form_initial + ) def service_login(self): """Perform login agains a service""" @@ -483,7 +556,38 @@ class LoginView(View, LogoutMixin): } return JsonResponse(self.request, data) else: - return render(self.request, settings.CAS_LOGIN_TEMPLATE, {'form': self.form}) + if settings.CAS_FEDERATE: + if self.username and self.ticket: + return render( + self.request, + settings.CAS_LOGIN_TEMPLATE, + { + 'form': self.form, + 'auto_submit': True, + 'post_url': reverse("cas_server:login") + } + ) + else: + if ( + self.request.COOKIES.get('_remember_provider') and + self.request.COOKIES['_remember_provider'] in + settings.CAS_FEDERATE_PROVIDERS + ): + params = utils.copy_params(self.request.GET) + url = utils.reverse_params( + "cas_server:federateAuth", + params=params, + kwargs=dict(provider=self.request.COOKIES['_remember_provider']) + ) + return HttpResponseRedirect(url) + else: + return render( + self.request, + settings.CAS_FEDERATE_TEMPLATE, + {'form': self.form} + ) + else: + return render(self.request, settings.CAS_LOGIN_TEMPLATE, {'form': self.form}) def common(self): """Part execute uppon GET and POST request""" From 7c16e2e36ca9b4dec15802213815e0da05d0e8af Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Fri, 17 Jun 2016 19:43:24 +0200 Subject: [PATCH 02/34] Need six, update version number --- requirements-dev.txt | 1 + requirements.txt | 2 +- setup.py | 5 +++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 9998ce7..e6ef993 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,3 +7,4 @@ django-picklefield>=0.3.1 requests_futures>=0.9.5 django-bootstrap3>=5.4 lxml>=3.4 +six>=1 diff --git a/requirements.txt b/requirements.txt index 38096e1..937a0d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,4 @@ requests_futures>=0.9.5 django-picklefield>=0.3.1 django-bootstrap3>=5.4 lxml>=3.4 - +six>=1 diff --git a/setup.py b/setup.py index a9826c7..5e90985 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ except pkg_resources.DistributionNotFound: setup( name='django-cas-server', - version='0.4.4', + version='0.5.0', packages=[ 'cas_server', 'cas_server.migrations', 'cas_server.management', 'cas_server.management.commands' @@ -67,7 +67,8 @@ setup( keywords=['django', 'cas', 'cas3', 'server', 'sso', 'single sign-on', 'authentication', 'auth'], install_requires=[ django, 'requests >= 2.4', 'requests_futures >= 0.9.5', - 'django-picklefield >= 0.3.1', django_bootstrap3, 'lxml >= 3.4' + 'django-picklefield >= 0.3.1', django_bootstrap3, 'lxml >= 3.4', + 'six >= 1' ], url="https://github.com/nitmir/django-cas-server", download_url="https://github.com/nitmir/django-cas-server/releases", From 5cf820e44f5ff0766ba49b51eaf99c12121ffe84 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sun, 19 Jun 2016 13:00:09 +0200 Subject: [PATCH 03/34] Add a command to clean old federated users --- .../management/commands/cas_clean_federate.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 cas_server/management/commands/cas_clean_federate.py diff --git a/cas_server/management/commands/cas_clean_federate.py b/cas_server/management/commands/cas_clean_federate.py new file mode 100644 index 0000000..4c60ed5 --- /dev/null +++ b/cas_server/management/commands/cas_clean_federate.py @@ -0,0 +1,19 @@ +from django.core.management.base import BaseCommand +from django.utils.translation import ugettext_lazy as _ +from django.utils import timezone + +from datetime import timedelta + +from ... import models +from ...default_settings import settings + + +class Command(BaseCommand): + args = '' + help = _(u"Clean old federated users") + + def handle(self, *args, **options): + federated_users = models.FederatedUser.objects.filter(last_update__lt=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_TIMEOUT))) + for user in federated_users: + if not models.User.objects.filter(username='%s@%s' % (user.username, user.provider)): + user.delete() From e8a587f48ab437a856ba417f36a14e309032f01f Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sun, 19 Jun 2016 13:00:28 +0200 Subject: [PATCH 04/34] Disable federated auth if CAS_FEDERATE is False --- cas_server/models.py | 3 +++ cas_server/views.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/cas_server/models.py b/cas_server/models.py index 746e7e6..40ff687 100644 --- a/cas_server/models.py +++ b/cas_server/models.py @@ -44,6 +44,9 @@ class FederatedUser(models.Model): ticket = models.CharField(max_length=255) last_update = models.DateTimeField(auto_now=True) + def __unicode__(self): + return u"%s@%s" % (self.username, self.provider) + class User(models.Model): """A user logged into the CAS""" diff --git a/cas_server/views.py b/cas_server/views.py index 733c53c..69662d5 100644 --- a/cas_server/views.py +++ b/cas_server/views.py @@ -182,6 +182,8 @@ class LogoutView(View, LogoutMixin): class FederateAuth(View): def post(self, request, provider=None): + if not settings.CAS_FEDERATE: + return redirect("cas_server:login") form = forms.FederateSelect(request.POST) if form.is_valid(): params = utils.copy_params( @@ -202,6 +204,8 @@ class FederateAuth(View): return redirect("cas_server:login") def get(self, request, provider=None): + if not settings.CAS_FEDERATE: + return redirect("cas_server:login") if provider not in settings.CAS_FEDERATE_PROVIDERS: return redirect("cas_server:login") service_url = utils.get_current_url(request, {"ticket", "provider"}) From 63a041463d94405db43181fa0ac16b81f7d47279 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Mon, 20 Jun 2016 13:38:50 +0200 Subject: [PATCH 05/34] No need for a different template for federated login --- cas_server/default_settings.py | 1 - cas_server/templates/cas_server/federate.html | 22 ------------------- cas_server/views.py | 7 ++++-- 3 files changed, 5 insertions(+), 25 deletions(-) delete mode 100644 cas_server/templates/cas_server/federate.html diff --git a/cas_server/default_settings.py b/cas_server/default_settings.py index 34b8aa5..51be0d8 100644 --- a/cas_server/default_settings.py +++ b/cas_server/default_settings.py @@ -21,7 +21,6 @@ def setting_default(name, default_value): setting_default('CAS_LOGO_URL', static("cas_server/logo.png")) setting_default('CAS_LOGIN_TEMPLATE', 'cas_server/login.html') -setting_default('CAS_FEDERATE_TEMPLATE', 'cas_server/federate.html') setting_default('CAS_WARN_TEMPLATE', 'cas_server/warn.html') setting_default('CAS_LOGGED_TEMPLATE', 'cas_server/logged.html') setting_default('CAS_LOGOUT_TEMPLATE', 'cas_server/logout.html') diff --git a/cas_server/templates/cas_server/federate.html b/cas_server/templates/cas_server/federate.html deleted file mode 100644 index 1411513..0000000 --- a/cas_server/templates/cas_server/federate.html +++ /dev/null @@ -1,22 +0,0 @@ -{% extends "cas_server/base.html" %} -{% load bootstrap3 %} -{% load staticfiles %} -{% load i18n %} -{% block content %} - -{% if auto_submit %} - -{% endif %} -{% endblock %} - diff --git a/cas_server/views.py b/cas_server/views.py index 9148816..7726eb0 100644 --- a/cas_server/views.py +++ b/cas_server/views.py @@ -587,8 +587,11 @@ class LoginView(View, LogoutMixin): else: return render( self.request, - settings.CAS_FEDERATE_TEMPLATE, - utils.context({'form': self.form}) + settings.CAS_LOGIN_TEMPLATE, + utils.context({ + 'form': self.form, + 'post_url': reverse("cas_server:federateAuth") + }) ) else: return render( From 3cef82b4758fd1b677406def1f5811549691f007 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Mon, 20 Jun 2016 13:51:40 +0200 Subject: [PATCH 06/34] Hide h1 if auto_submit is enable --- cas_server/templates/cas_server/base.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cas_server/templates/cas_server/base.html b/cas_server/templates/cas_server/base.html index cb3a93e..b332841 100644 --- a/cas_server/templates/cas_server/base.html +++ b/cas_server/templates/cas_server/base.html @@ -12,7 +12,8 @@ {% block bootstrap3_content %}
-
+{% if auto_submit %}{% endif %}
From 177da450aa23b4e57268168d150dfcf46fe9f992 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Mon, 20 Jun 2016 14:14:50 +0200 Subject: [PATCH 07/34] Add headers to cas.py --- cas_server/cas.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/cas_server/cas.py b/cas_server/cas.py index bea0638..b12359d 100644 --- a/cas_server/cas.py +++ b/cas_server/cas.py @@ -1,3 +1,26 @@ +# Copyright (C) 2014, Ming Chen +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is furnished +# to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +# This file is originated from https://github.com/python-cas/python-cas +# at commit ec1f2d4779625229398547b9234d0e9e874a2c9a + from six.moves.urllib import parse as urllib_parse from six.moves.urllib import request as urllib_request from six.moves.urllib.request import Request From 2b02568e5b8c7e205ae14334918c962711b4b528 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Mon, 20 Jun 2016 14:15:00 +0200 Subject: [PATCH 08/34] Doc for the federated mode --- README.rst | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/README.rst b/README.rst index 78eaae1..44247cf 100644 --- a/README.rst +++ b/README.rst @@ -139,6 +139,15 @@ Authentication settings: If more requests need to be send, there are queued. The default is ``10``. * ``CAS_SLO_TIMEOUT``: Timeout for a single SLO request in seconds. The default is ``5``. + +Federation settings: + +* ``CAS_FEDERATE``: A boolean for activating the federated mode (see the federate section below). + The default is ``False``. +* ``CAS_FEDERATE_PROVIDERS``: A distionnary for the allowed identity providers (see the federate + section below). The default is ``{}``. + + Tickets validity settings: * ``CAS_TICKET_VALIDITY``: Number of seconds the service tickets and proxy tickets are valid. @@ -199,6 +208,8 @@ Authentication backend This is the default backend. The returned attributes are the fields available on the user model. * mysql backend ``cas_server.auth.MysqlAuthUser``: see the 'Mysql backend settings' section. The returned attributes are those return by sql query ``CAS_SQL_USER_QUERY``. +* federated backend ``cas_server.auth.CASFederateAuth``: It is automatically used then ``CAS_FEDERATE`` is ``True``. + You should not set it manually without setting ``CAS_FEDERATE`` to ``True``. Logs ---- @@ -267,3 +278,32 @@ Or to log to a file: }, }, } + + +Federation mode +--------------- + +``django-cas-server`` comes with a federation mode. Then ``CAS_FEDERATE`` is ``True``, +user are invited to choose an identity provider on the login page, then, they are redirected +to the provider CAS to authenticate. This provider transmit to ``django-cas-server`` the user +username and attributes. The user is now logged in on ``django-cas-server`` and can user +services using ``django-cas-server`` as CAS. + +The list of allowed identity providers is defined using the ``CAS_FEDERATE_PROVIDERS`` parameter. +For instance: + +.. code-block:: python + + CAS_FEDERATE_PROVIDERS = { + "example.com": ("https://cas.example.com", 3), + "exemple.fr": ("https://cas.exemple.fr", 3), + } + + +``CAS_FEDERATE_PROVIDERS`` is a dictionnary using provider names as key and a tuple +(cas address, cas version protocol) as value. + +In federation mode, ``django-cas-server`` build user's username as follow: +``provider_returned_username@provider_name``. +You can choose the provider returned username for ``django-cas-server`` and the provider name +in order than to builed username make sense. From 0dba0d1776cedeb1968d5c608b3a726a8286894c Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Mon, 20 Jun 2016 14:20:49 +0200 Subject: [PATCH 09/34] Style and federated doc about the manage command cas_clean_federate --- README.rst | 13 +++++++++++-- .../management/commands/cas_clean_federate.py | 4 +++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 44247cf..539e453 100644 --- a/README.rst +++ b/README.rst @@ -286,7 +286,7 @@ Federation mode ``django-cas-server`` comes with a federation mode. Then ``CAS_FEDERATE`` is ``True``, user are invited to choose an identity provider on the login page, then, they are redirected to the provider CAS to authenticate. This provider transmit to ``django-cas-server`` the user -username and attributes. The user is now logged in on ``django-cas-server`` and can user +username and attributes. The user is now logged in on ``django-cas-server`` and can use services using ``django-cas-server`` as CAS. The list of allowed identity providers is defined using the ``CAS_FEDERATE_PROVIDERS`` parameter. @@ -306,4 +306,13 @@ For instance: In federation mode, ``django-cas-server`` build user's username as follow: ``provider_returned_username@provider_name``. You can choose the provider returned username for ``django-cas-server`` and the provider name -in order than to builed username make sense. +in order to make sense. + + +Then using federate mode, you should add one command to a daily crontab: ``cas_clean_federate``. +This command clean the local cache of federated user from old unused users. + You could for example do as bellow : + + .. code-block:: + + 10 0 * * * cas-user /path/to/project/manage.py cas_clean_federate diff --git a/cas_server/management/commands/cas_clean_federate.py b/cas_server/management/commands/cas_clean_federate.py index 4c60ed5..982982a 100644 --- a/cas_server/management/commands/cas_clean_federate.py +++ b/cas_server/management/commands/cas_clean_federate.py @@ -13,7 +13,9 @@ class Command(BaseCommand): help = _(u"Clean old federated users") def handle(self, *args, **options): - federated_users = models.FederatedUser.objects.filter(last_update__lt=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_TIMEOUT))) + federated_users = models.FederatedUser.objects.filter( + last_update__lt=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_TIMEOUT)) + ) for user in federated_users: if not models.User.objects.filter(username='%s@%s' % (user.username, user.provider)): user.delete() From 16fd0f5dbb1d5c5bc3a5631128fb6c74ed7dac3a Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Mon, 20 Jun 2016 14:21:57 +0200 Subject: [PATCH 10/34] style --- README.rst | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 539e453..fc2d188 100644 --- a/README.rst +++ b/README.rst @@ -311,8 +311,10 @@ in order to make sense. Then using federate mode, you should add one command to a daily crontab: ``cas_clean_federate``. This command clean the local cache of federated user from old unused users. - You could for example do as bellow : - .. code-block:: - 10 0 * * * cas-user /path/to/project/manage.py cas_clean_federate +You could for example do as bellow : + +.. code-block:: + + 10 0 * * * cas-user /path/to/project/manage.py cas_clean_federate From 25ece526a0a0072b1762e7ed6a058ca4428be13b Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Tue, 21 Jun 2016 00:17:04 +0200 Subject: [PATCH 11/34] Update translation --- cas_server/locale/en/LC_MESSAGES/django.mo | Bin 5913 -> 6117 bytes cas_server/locale/en/LC_MESSAGES/django.po | 136 +++++++++++---------- cas_server/locale/fr/LC_MESSAGES/django.mo | Bin 7041 -> 7285 bytes cas_server/locale/fr/LC_MESSAGES/django.po | 136 +++++++++++---------- 4 files changed, 148 insertions(+), 124 deletions(-) diff --git a/cas_server/locale/en/LC_MESSAGES/django.mo b/cas_server/locale/en/LC_MESSAGES/django.mo index 686826e4d73ccf0812a46a7ec01e94242ffead37..3ebf9a68d87017e3222c7d49705f72c4480bb712 100644 GIT binary patch delta 1411 zcma*n%}Z2K7{~F)@g0PupoWc7QVt;$BtbG* z8l;7rRxO;31#McjXi;t23HDyl9}u+Y`fLLYin$z$ zum~9+wiZr&TsVo@I1;~M*Rg>9JO(g|8u%HrFnO!l4$Q|EtiocvfUL!CA#1c*w|@_t z=r3Y3zQY>kw;ZooIv3io8oN*vO`$UK0JYO+xE#Oza&3i zvP#tW0BQrxNY<^@z2AjeKsTynx3LOW!<NcdO$_>k}R^2TpYcbw3L`} z&KYpV`a;ftfBcd&7AZ)*pE=-+goZ*_{1?YYCx*BiT}d5GC_3Q`JHuz4F@I>#@pJjF zXMEnf9&fNZQ17p+J6zuod74)AryGo{r@i+ydEIGGCq|BBP5;-_o!MtR|Cl=B^Ztr1 I=T0R225VlTuK)l5 delta 1245 zcmY+@OGs2<7{>8;ZkA(~j%lW*V;8f_=s2Sp<_JmDMrxId1Su*t3TB~3w3$Uk3nPM) ziz0IoM2jG5s!c=)6|-s4qE1U2q#WVir@F!zL^#rLYBAlf}Gw68AIj!2>vsZTJMs zF^@5xZv}p6=XGIs0o}NP`BCh|Vbp^hEH1z^hpfToQ8!*hEugy0jAmIq9>ORN<5|@C zZ;{R02hDlDed32M%;S1o@J{%JTEH^y#`rq-#zQztOeb-c<2kmYXvVmB3jd%wd5v8t zntP~dj#apkT=#sB8D$fyH0#G+RI4+n8{9`l^TM0wP|4eJ8y615`ARQPDiDrvJ+3w|ByEo}z4qQ8ySttvro%)h50FUqvn81}d64+=~Sy z8H-cA4%SX-L#xMDChDRVQpWRFhFX}Qwo(3-qwc4vUF$8^ZuDhFDUDhe+l1;w2epe* zELso=6&J2e?4UHZyBK@c`_HFC)l;q17K*s6nPM|WRK=mL?DT#s8pXoej!( zds+Na)EPdm|3~jwvTqzacruzu v_H@Oa-txxP(=+AYgWYW%VdrD0i7N*K&g;r6#jBShLw;u_yqrzfT=e|~Cr*3s diff --git a/cas_server/locale/en/LC_MESSAGES/django.po b/cas_server/locale/en/LC_MESSAGES/django.po index e513335..805843a 100644 --- a/cas_server/locale/en/LC_MESSAGES/django.po +++ b/cas_server/locale/en/LC_MESSAGES/django.po @@ -7,40 +7,52 @@ msgid "" msgstr "" "Project-Id-Version: cas_server\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2016-05-03 23:50+0200\n" -"PO-Revision-Date: 2016-05-03 23:50+0200\n" +"POT-Creation-Date: 2016-06-21 00:14+0200\n" +"PO-Revision-Date: 2016-06-21 00:16+0200\n" "Last-Translator: Valentin Samir \n" "Language-Team: django \n" "Language: en\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: Poedit 1.8.7.1\n" +"X-Generator: Poedit 1.8.8\n" -#: apps.py:7 templates/cas_server/base.html:3 +#: apps.py:7 templates/cas_server/base.html:3 templates/cas_server/base.html:21 msgid "Central Authentication Service" msgstr "Central Authentication Service" -#: forms.py:23 -msgid "login" -msgstr "username" +#: forms.py:32 +msgid "Identity provider" +msgstr "Identity provider" -#: forms.py:24 forms.py:47 +#: forms.py:35 forms.py:44 forms.py:92 msgid "service" msgstr "" -#: forms.py:25 -msgid "password" -msgstr "password" +#: forms.py:37 +msgid "Remember the identity provider" +msgstr "Remember the identity provider" -#: forms.py:28 +#: forms.py:38 forms.py:48 msgid "warn" msgstr " Warn me before logging me into other sites." -#: forms.py:39 +#: forms.py:43 +msgid "login" +msgstr "username" + +#: forms.py:45 +msgid "password" +msgstr "password" + +#: forms.py:59 msgid "Bad user" msgstr "The credentials you provided cannot be determined to be authentic." +#: management/commands/cas_clean_federate.py:13 +msgid "Clean old federated users" +msgstr "Clean old federated users" + #: management/commands/cas_clean_sessions.py:9 msgid "Clean deleted sessions" msgstr "Clean deleted sessions" @@ -49,44 +61,44 @@ msgstr "Clean deleted sessions" msgid "Clean old trickets" msgstr "Clean old trickets" -#: models.py:42 +#: models.py:55 msgid "User" msgstr "" -#: models.py:43 +#: models.py:56 msgid "Users" msgstr "" -#: models.py:101 +#: models.py:114 #, python-format msgid "Error during service logout %s" msgstr "Error during service logout %s" -#: models.py:169 +#: models.py:182 msgid "Service pattern" msgstr "Service pattern" -#: models.py:170 +#: models.py:183 msgid "Services patterns" msgstr "" -#: models.py:174 +#: models.py:187 msgid "position" msgstr "position" -#: models.py:181 models.py:303 +#: models.py:194 models.py:316 msgid "name" msgstr "name" -#: models.py:182 +#: models.py:195 msgid "A name for the service" msgstr "A name for the service" -#: models.py:187 models.py:331 models.py:349 +#: models.py:200 models.py:344 models.py:362 msgid "pattern" msgstr "pattern" -#: models.py:189 +#: models.py:202 msgid "" "A regular expression matching services. Will usually looks like '^https://" "some\\.server\\.com/path/.*$'.As it is a regular expression, special " @@ -96,73 +108,73 @@ msgstr "" "some\\.server\\.com/path/.*$'.As it is a regular expression, special " "character must be escaped with a '\\'." -#: models.py:198 +#: models.py:211 msgid "user field" msgstr "" -#: models.py:199 +#: models.py:212 msgid "Name of the attribut to transmit as username, empty = login" msgstr "Name of the attribut to transmit as username, empty = login" -#: models.py:203 +#: models.py:216 msgid "restrict username" msgstr "" -#: models.py:204 +#: models.py:217 msgid "Limit username allowed to connect to the list provided bellow" msgstr "Limit username allowed to connect to the list provided bellow" -#: models.py:208 +#: models.py:221 msgid "proxy" msgstr "proxy" -#: models.py:209 +#: models.py:222 msgid "Proxy tickets can be delivered to the service" msgstr "Proxy tickets can be delivered to the service" -#: models.py:213 +#: models.py:226 msgid "proxy callback" msgstr "proxy callback" -#: models.py:214 +#: models.py:227 msgid "can be used as a proxy callback to deliver PGT" msgstr "can be used as a proxy callback to deliver PGT" -#: models.py:218 +#: models.py:231 msgid "single log out" msgstr "" -#: models.py:219 +#: models.py:232 msgid "Enable SLO for the service" msgstr "Enable SLO for the service" -#: models.py:226 +#: models.py:239 msgid "single log out callback" msgstr "" -#: models.py:227 +#: models.py:240 msgid "" "URL where the SLO request will be POST. empty = service url\n" "This is usefull for non HTTP proxied services." msgstr "" -#: models.py:288 +#: models.py:301 msgid "username" msgstr "" -#: models.py:289 +#: models.py:302 msgid "username allowed to connect to the service" msgstr "username allowed to connect to the service" -#: models.py:304 +#: models.py:317 msgid "name of an attribut to send to the service, use * for all attributes" msgstr "name of an attribut to send to the service, use * for all attributes" -#: models.py:309 models.py:355 +#: models.py:322 models.py:368 msgid "replace" msgstr "replace" -#: models.py:310 +#: models.py:323 msgid "" "name under which the attribut will be showto the service. empty = default " "name of the attribut" @@ -170,31 +182,31 @@ msgstr "" "name under which the attribut will be showto the service. empty = default " "name of the attribut" -#: models.py:326 models.py:344 +#: models.py:339 models.py:357 msgid "attribut" msgstr "attribut" -#: models.py:327 +#: models.py:340 msgid "Name of the attribut which must verify pattern" msgstr "Name of the attribut which must verify pattern" -#: models.py:332 +#: models.py:345 msgid "a regular expression" msgstr "a regular expression" -#: models.py:345 +#: models.py:358 msgid "Name of the attribut for which the value must be replace" msgstr "Name of the attribut for which the value must be replace" -#: models.py:350 +#: models.py:363 msgid "An regular expression maching whats need to be replaced" msgstr "An regular expression maching whats need to be replaced" -#: models.py:356 +#: models.py:369 msgid "replace expression, groups are capture by \\1, \\2 …" msgstr "replace expression, groups are capture by \\1, \\2 …" -#: models.py:463 +#: models.py:476 #, python-format msgid "" "Error during service logout %(service)s:\n" @@ -219,19 +231,19 @@ msgstr "Log me out from all my sessions" msgid "Logout" msgstr "Logout" -#: templates/cas_server/login.html:7 +#: templates/cas_server/login.html:8 msgid "Please log in" msgstr "Please log in" -#: templates/cas_server/login.html:10 +#: templates/cas_server/login.html:13 msgid "Login" msgstr "Login" -#: templates/cas_server/warn.html:7 +#: templates/cas_server/warn.html:10 msgid "Connect to the service" msgstr "Connect to the service" -#: views.py:128 +#: views.py:140 msgid "" "

Logout successful

You have successfully logged out from the Central " "Authentication Service. For security reasons, exit your web browser." @@ -239,7 +251,7 @@ msgstr "" "

Logout successful

You have successfully logged out from the Central " "Authentication Service. For security reasons, exit your web browser." -#: views.py:134 +#: views.py:146 #, python-format msgid "" "

Logout successful

You have successfully logged out from %s sessions " @@ -250,7 +262,7 @@ msgstr "" "of the Central Authentication Service. For security reasons, exit your web " "browser." -#: views.py:141 +#: views.py:153 msgid "" "

Logout successful

You were already logged out from the Central " "Authentication Service. For security reasons, exit your web browser." @@ -258,44 +270,44 @@ msgstr "" "

Logout successful

You were already logged out from the Central " "Authentication Service. For security reasons, exit your web browser." -#: views.py:230 +#: views.py:294 msgid "Invalid login ticket" msgstr "Invalid login ticket, please retry to login" -#: views.py:325 +#: views.py:410 #, python-format msgid "Authentication has been required by service %(name)s (%(url)s)" msgstr "Authentication has been required by service %(name)s (%(url)s)" -#: views.py:359 +#: views.py:448 #, python-format msgid "Service %(url)s non allowed." msgstr "Service %(url)s non allowed." -#: views.py:366 +#: views.py:455 msgid "Username non allowed" msgstr "Username non allowed" -#: views.py:373 +#: views.py:462 msgid "User charateristics non allowed" msgstr "User charateristics non allowed" -#: views.py:380 +#: views.py:469 #, python-format msgid "The attribut %(field)s is needed to use that service" msgstr "The attribut %(field)s is needed to use that service" -#: views.py:450 +#: views.py:539 #, python-format msgid "Authentication renewal required by service %(name)s (%(url)s)." msgstr "Authentication renewal required by service %(name)s (%(url)s)." -#: views.py:457 +#: views.py:546 #, python-format msgid "Authentication required by service %(name)s (%(url)s)." msgstr "Authentication required by service %(name)s (%(url)s)." -#: views.py:464 +#: views.py:553 #, python-format msgid "Service %s non allowed" msgstr "Service %s non allowed" diff --git a/cas_server/locale/fr/LC_MESSAGES/django.mo b/cas_server/locale/fr/LC_MESSAGES/django.mo index c8d14ba489f7d7b95570bc469dc36dac8d1546ba..5b0180af5885870fe08c3dc4b0859370d8b631ad 100644 GIT binary patch delta 1588 zcmZ|PZ)lBC9KiA4y}Py<`(qp1unR~Ijb^k0&xxA7j z>gELzFSNRrWH*Zlb-nOX$;#9uZ_r4JlsCS=X9uO6?zx}moO7S^ob&sg=bmc$=8I1h zdM*lOEpK_FSt2d?1lQpYV@tM3GwlK7 zBe(dfz)|G>xV+?|jE+ex#LVea6PMv!+Fo3VTTpL2fwS-`7UONy#XrSLbmuS!Rv~MZ zTGL*EKHAOLhJ9Gi^JSQeTsp?F4&S3bXwHnOh%}+@vBQ78U{7M8IsvL>ylJKV&VhWr4w;t|yQhEQLykvJEdxfnIN z=1ono6*afJQ771E+5s~j!u9kY$4Y#FdNy7o*^zg~Z|I|)#k<{z?MT3!!jQ!EAZXD+ z#++#A>!h!cmetgS&Xvd_<8QoN=)uy+Y0Kba1|$;xV?ME>bt9izI7t=YVkT zWI0uLqTz3)YN?|Vd09-gsQfqmDa~9pm{u)n*jG?B|C+Mjg~0wP`ahy+)!b`T%JtZC zHk_Q)Bj?TuH#lV#-=x;bF^5@}P|Y8sbC76CqBgTKRWj{bMqZEYx5N9Rw%>|H>~P{{ z_T>V<9f}5{gI0gIZy?BMs$+VxdXClfD< u-jww8#loRrB*Kv8uQ88ICsTKdf4Zx?Z7b3j8?Zw`24mL$emh(;mhlTWLBK-* delta 1364 zcmZY9OGs2v9LMqhIAiA9e3V(Kl~!6~>O3_|NGT{m!bPsEMGq=+RTf!HwWvk(a?og{ z5Cw$`nIVbj0YwObL|`o_uTTa2R^!*LAZ*)+2)_`vZS2I&VEn(=2x z`6$I6WPZf@xyWZ=I5}WXv4s9xtjDjY2^HyPh1i0du@lR10C~2JB5Sk9PX9TE>A%EH z=*}>!!Dh_HZuIheJI#f5eh;;yG0eqDT!ho;N6R!j3;`q=HiTNhZPdbEpce4X>3>Al zZnLNd|8lW}ue4PSf9xtr(;}yRa9x;y5a$c?HRS z8?K^%6<1;mRl*5ef-|@R(@2xfRwXv!@j~+7!bOaMPMpIv*j{AjVx>Ju`RpWhQAy6D zCidYze1+@L&r|rR?Zp80;949;EocJSo6R9>x3uEq#!8CG|78aDFhF^13U$MIEXT^F zW@Ov8p?1`PN^LjljX8mu7el=;PjCl5bIfLv=69k>xF0qDh|@n7aeiQIfL zYWf*f;~7WSvgD~QKtJPcNTL#N3M85`5h8RSZHB#AC84G)sOdneMU-x}96~iz(~Z?Q zC)Pmd?a(6rv=rx}3hM|hRJ+sRYa~=EWksd)5qdpq3AG@h66ruvibx_Il-<}!;&})`d^~9fhaMy5hW7 zzY-rwKj4Z7GMn5P0dK9Zyx!O74@G-(O8<2m;p(i>}_&Odb`jHYJ IDt_bs4LA>h=Kufz diff --git a/cas_server/locale/fr/LC_MESSAGES/django.po b/cas_server/locale/fr/LC_MESSAGES/django.po index fd50dbd..ed875c6 100644 --- a/cas_server/locale/fr/LC_MESSAGES/django.po +++ b/cas_server/locale/fr/LC_MESSAGES/django.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: cas_server\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2016-05-03 23:47+0200\n" -"PO-Revision-Date: 2016-05-03 23:49+0200\n" +"POT-Creation-Date: 2016-06-21 00:14+0200\n" +"PO-Revision-Date: 2016-06-21 00:15+0200\n" "Last-Translator: Valentin Samir \n" "Language-Team: django \n" "Language: fr\n" @@ -16,32 +16,44 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" -"X-Generator: Poedit 1.8.7.1\n" +"X-Generator: Poedit 1.8.8\n" -#: apps.py:7 templates/cas_server/base.html:3 +#: apps.py:7 templates/cas_server/base.html:3 templates/cas_server/base.html:21 msgid "Central Authentication Service" msgstr "Service Central d'Authentification" -#: forms.py:23 -msgid "login" -msgstr "Identifiant" +#: forms.py:32 +msgid "Identity provider" +msgstr "fournisseur d'identité" -#: forms.py:24 forms.py:47 +#: forms.py:35 forms.py:44 forms.py:92 msgid "service" msgstr "service" -#: forms.py:25 -msgid "password" -msgstr "mot de passe" +#: forms.py:37 +msgid "Remember the identity provider" +msgstr "Se souvenir du fournisseur d'identité" -#: forms.py:28 +#: forms.py:38 forms.py:48 msgid "warn" msgstr "Prévenez-moi avant d'accéder à d'autres services." -#: forms.py:39 +#: forms.py:43 +msgid "login" +msgstr "Identifiant" + +#: forms.py:45 +msgid "password" +msgstr "mot de passe" + +#: forms.py:59 msgid "Bad user" msgstr "Les informations transmises n'ont pas permis de vous authentifier." +#: management/commands/cas_clean_federate.py:13 +msgid "Clean old federated users" +msgstr "Nettoyer les anciens utilisateurs fédéré" + #: management/commands/cas_clean_sessions.py:9 msgid "Clean deleted sessions" msgstr "Nettoyer les sessions supprimées" @@ -50,44 +62,44 @@ msgstr "Nettoyer les sessions supprimées" msgid "Clean old trickets" msgstr "Nettoyer les vieux tickets" -#: models.py:42 +#: models.py:55 msgid "User" msgstr "Utilisateur" -#: models.py:43 +#: models.py:56 msgid "Users" msgstr "Utilisateurs" -#: models.py:101 +#: models.py:114 #, python-format msgid "Error during service logout %s" msgstr "Une erreur est survenue durant la déconnexion du service %s" -#: models.py:169 +#: models.py:182 msgid "Service pattern" msgstr "Motif de service" -#: models.py:170 +#: models.py:183 msgid "Services patterns" msgstr "Motifs de services" -#: models.py:174 +#: models.py:187 msgid "position" msgstr "position" -#: models.py:181 models.py:303 +#: models.py:194 models.py:316 msgid "name" msgstr "nom" -#: models.py:182 +#: models.py:195 msgid "A name for the service" msgstr "Un nom pour le service" -#: models.py:187 models.py:331 models.py:349 +#: models.py:200 models.py:344 models.py:362 msgid "pattern" msgstr "motif" -#: models.py:189 +#: models.py:202 msgid "" "A regular expression matching services. Will usually looks like '^https://" "some\\.server\\.com/path/.*$'.As it is a regular expression, special " @@ -98,55 +110,55 @@ msgstr "" "expression rationnelle, les caractères spéciaux doivent être échappés avec " "un '\\'." -#: models.py:198 +#: models.py:211 msgid "user field" msgstr "champ utilisateur" -#: models.py:199 +#: models.py:212 msgid "Name of the attribut to transmit as username, empty = login" msgstr "" "Nom de l'attribut devant être transmis comme nom d'utilisateur au service. " "vide = nom de connection" -#: models.py:203 +#: models.py:216 msgid "restrict username" msgstr "limiter les noms d'utilisateurs" -#: models.py:204 +#: models.py:217 msgid "Limit username allowed to connect to the list provided bellow" msgstr "" "Limiter les noms d'utilisateurs autorisé à se connecter à la liste fournie " "ci-dessous" -#: models.py:208 +#: models.py:221 msgid "proxy" msgstr "proxy" -#: models.py:209 +#: models.py:222 msgid "Proxy tickets can be delivered to the service" msgstr "des proxy tickets peuvent être délivrés au service" -#: models.py:213 +#: models.py:226 msgid "proxy callback" msgstr "" -#: models.py:214 +#: models.py:227 msgid "can be used as a proxy callback to deliver PGT" msgstr "peut être utilisé comme un callback pour recevoir un PGT" -#: models.py:218 +#: models.py:231 msgid "single log out" msgstr "" -#: models.py:219 +#: models.py:232 msgid "Enable SLO for the service" msgstr "Active le SLO pour le service" -#: models.py:226 +#: models.py:239 msgid "single log out callback" msgstr "" -#: models.py:227 +#: models.py:240 msgid "" "URL where the SLO request will be POST. empty = service url\n" "This is usefull for non HTTP proxied services." @@ -155,55 +167,55 @@ msgstr "" "service\n" "Ceci n'est utilise que pour des services non HTTP proxifiés" -#: models.py:288 +#: models.py:301 msgid "username" msgstr "nom d'utilisateur" -#: models.py:289 +#: models.py:302 msgid "username allowed to connect to the service" msgstr "noms d'utilisateurs autorisé à se connecter au service" -#: models.py:304 +#: models.py:317 msgid "name of an attribut to send to the service, use * for all attributes" msgstr "" "nom d'un attribut a envoyer au service, utiliser * pour tous les attributs" -#: models.py:309 models.py:355 +#: models.py:322 models.py:368 msgid "replace" msgstr "remplacement" -#: models.py:310 +#: models.py:323 msgid "" "name under which the attribut will be showto the service. empty = default " "name of the attribut" msgstr "" "nom sous lequel l'attribut sera rendu visible au service. vide = inchangé" -#: models.py:326 models.py:344 +#: models.py:339 models.py:357 msgid "attribut" msgstr "attribut" -#: models.py:327 +#: models.py:340 msgid "Name of the attribut which must verify pattern" msgstr "Nom de l'attribut devant vérifier un motif" -#: models.py:332 +#: models.py:345 msgid "a regular expression" msgstr "une expression régulière" -#: models.py:345 +#: models.py:358 msgid "Name of the attribut for which the value must be replace" msgstr "nom de l'attribue pour lequel la valeur doit être remplacé" -#: models.py:350 +#: models.py:363 msgid "An regular expression maching whats need to be replaced" msgstr "une expression régulière reconnaissant ce qui doit être remplacé" -#: models.py:356 +#: models.py:369 msgid "replace expression, groups are capture by \\1, \\2 …" msgstr "expression de remplacement, les groupe sont capturé par \\1, \\2" -#: models.py:463 +#: models.py:476 #, python-format msgid "" "Error during service logout %(service)s:\n" @@ -228,19 +240,19 @@ msgstr "Me déconnecter de toutes mes sessions" msgid "Logout" msgstr "Se déconnecter" -#: templates/cas_server/login.html:7 +#: templates/cas_server/login.html:8 msgid "Please log in" msgstr "Merci de se connecter" -#: templates/cas_server/login.html:10 +#: templates/cas_server/login.html:13 msgid "Login" msgstr "Connexion" -#: templates/cas_server/warn.html:7 +#: templates/cas_server/warn.html:10 msgid "Connect to the service" msgstr "Se connecter au service" -#: views.py:128 +#: views.py:140 msgid "" "

Logout successful

You have successfully logged out from the Central " "Authentication Service. For security reasons, exit your web browser." @@ -249,7 +261,7 @@ msgstr "" "d'Authentification. Pour des raisons de sécurité, veuillez fermer votre " "navigateur." -#: views.py:134 +#: views.py:146 #, python-format msgid "" "

Logout successful

You have successfully logged out from %s sessions " @@ -260,7 +272,7 @@ msgstr "" "Service Central d'Authentification. Pour des raisons de sécurité, veuillez " "fermer votre navigateur." -#: views.py:141 +#: views.py:153 msgid "" "

Logout successful

You were already logged out from the Central " "Authentication Service. For security reasons, exit your web browser." @@ -269,46 +281,46 @@ msgstr "" "d'Authentification. Pour des raisons de sécurité, veuillez fermer votre " "navigateur." -#: views.py:230 +#: views.py:294 msgid "Invalid login ticket" msgstr "Ticket de connexion invalide, merci de réessayé de vous connecter" -#: views.py:325 +#: views.py:410 #, python-format msgid "Authentication has been required by service %(name)s (%(url)s)" msgstr "" "Une demande d'authentification a été émise pour le service %(name)s " "(%(url)s)." -#: views.py:359 +#: views.py:448 #, python-format msgid "Service %(url)s non allowed." msgstr "le service %(url)s n'est pas autorisé." -#: views.py:366 +#: views.py:455 msgid "Username non allowed" msgstr "Nom d'utilisateur non authorisé" -#: views.py:373 +#: views.py:462 msgid "User charateristics non allowed" msgstr "Caractéristique utilisateur non autorisée" -#: views.py:380 +#: views.py:469 #, python-format msgid "The attribut %(field)s is needed to use that service" msgstr "L'attribut %(field)s est nécessaire pour se connecter à ce service" -#: views.py:450 +#: views.py:539 #, python-format msgid "Authentication renewal required by service %(name)s (%(url)s)." msgstr "Demande de réauthentification pour le service %(name)s (%(url)s)." -#: views.py:457 +#: views.py:546 #, python-format msgid "Authentication required by service %(name)s (%(url)s)." msgstr "Authentification requise par le service %(name)s (%(url)s)." -#: views.py:464 +#: views.py:553 #, python-format msgid "Service %s non allowed" msgstr "Le service %s n'est pas autorisé" From 46cfaa67452507629e58bf53d7205b031667d297 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Tue, 21 Jun 2016 09:53:43 +0200 Subject: [PATCH 12/34] Fix python3 error "'dict_keys' object has no attribute 'sort'" --- cas_server/default_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cas_server/default_settings.py b/cas_server/default_settings.py index 51be0d8..7b9aca1 100644 --- a/cas_server/default_settings.py +++ b/cas_server/default_settings.py @@ -82,5 +82,5 @@ setting_default('CAS_FEDERATE_PROVIDERS', {}) if settings.CAS_FEDERATE: settings.CAS_AUTH_CLASS = "cas_server.auth.CASFederateAuth" -CAS_FEDERATE_PROVIDERS_LIST = settings.CAS_FEDERATE_PROVIDERS.keys() +CAS_FEDERATE_PROVIDERS_LIST = list(settings.CAS_FEDERATE_PROVIDERS.keys()) CAS_FEDERATE_PROVIDERS_LIST.sort() From a00e5d403d9b42bfcce51114fa0a24437c667f64 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Tue, 21 Jun 2016 14:42:35 +0200 Subject: [PATCH 13/34] Add a verbone names to CAS_FEDERATE_PROVIDERS --- README.rst | 8 +++++--- cas_server/default_settings.py | 28 +++++++++++++++++++++++++--- cas_server/forms.py | 9 +++++++-- cas_server/utils.py | 7 +++++++ 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/README.rst b/README.rst index fc2d188..3cf60fb 100644 --- a/README.rst +++ b/README.rst @@ -295,19 +295,21 @@ For instance: .. code-block:: python CAS_FEDERATE_PROVIDERS = { - "example.com": ("https://cas.example.com", 3), - "exemple.fr": ("https://cas.exemple.fr", 3), + "example.com": ("https://cas.example.com", 3, "Example dot com"), + "exemple.fr": ("https://cas.exemple.fr", 3, "Exemple point fr"), } ``CAS_FEDERATE_PROVIDERS`` is a dictionnary using provider names as key and a tuple -(cas address, cas version protocol) as value. +(cas address, cas version protocol, provider verbose name) as value. In federation mode, ``django-cas-server`` build user's username as follow: ``provider_returned_username@provider_name``. You can choose the provider returned username for ``django-cas-server`` and the provider name in order to make sense. +The "provider verbose name" is showed on the select menu of the login page. + Then using federate mode, you should add one command to a daily crontab: ``cas_clean_federate``. This command clean the local cache of federated user from old unused users. diff --git a/cas_server/default_settings.py b/cas_server/default_settings.py index 7b9aca1..2f32134 100644 --- a/cas_server/default_settings.py +++ b/cas_server/default_settings.py @@ -12,6 +12,9 @@ from django.conf import settings from django.contrib.staticfiles.templatetags.staticfiles import static +import re +import six + def setting_default(name, default_value): """if the config `name` is not set, set it the `default_value`""" @@ -76,11 +79,30 @@ setting_default('CAS_SQL_PASSWORD_CHECK', 'crypt') # crypt or plain setting_default('CAS_FEDERATE', False) -# A dict of "provider name" -> (provider CAS server url, CAS version) +# A dict of "provider suffix" -> (provider CAS server url, CAS version, verbose name) setting_default('CAS_FEDERATE_PROVIDERS', {}) if settings.CAS_FEDERATE: settings.CAS_AUTH_CLASS = "cas_server.auth.CASFederateAuth" -CAS_FEDERATE_PROVIDERS_LIST = list(settings.CAS_FEDERATE_PROVIDERS.keys()) -CAS_FEDERATE_PROVIDERS_LIST.sort() +__CAS_FEDERATE_PROVIDERS_LIST = list(settings.CAS_FEDERATE_PROVIDERS.keys()) + + +def __CAS_FEDERATE_PROVIDERS_LIST_sort(key): + if len(settings.CAS_FEDERATE_PROVIDERS[key]) > 2: + key = settings.CAS_FEDERATE_PROVIDERS[key][2].lower() + else: + key = key.lower() + if isinstance(key, six.string_types) or isinstance(key, six.text_type): + return tuple( + int(num) if num else alpha + for num, alpha in __CAS_FEDERATE_PROVIDERS_LIST_sort.tokenize(key) + ) + else: + return key + + +__CAS_FEDERATE_PROVIDERS_LIST_sort.tokenize = re.compile(r'(\d+)|(\D+)').findall +__CAS_FEDERATE_PROVIDERS_LIST.sort(key=__CAS_FEDERATE_PROVIDERS_LIST_sort) + +settings.CAS_FEDERATE_PROVIDERS_LIST = __CAS_FEDERATE_PROVIDERS_LIST diff --git a/cas_server/forms.py b/cas_server/forms.py index 33b3a2c..1036cf5 100644 --- a/cas_server/forms.py +++ b/cas_server/forms.py @@ -9,7 +9,7 @@ # # (c) 2015 Valentin Samir """forms for the app""" -from .default_settings import settings, CAS_FEDERATE_PROVIDERS_LIST +from .default_settings import settings from django import forms from django.utils.translation import ugettext_lazy as _ @@ -30,7 +30,12 @@ class WarnForm(forms.Form): class FederateSelect(forms.Form): provider = forms.ChoiceField( label=_('Identity provider'), - choices=[(p, p) for p in CAS_FEDERATE_PROVIDERS_LIST] + choices=[ + ( + p, + utils.get_tuple(settings.CAS_FEDERATE_PROVIDERS[p], 2, p) + ) for p in settings.CAS_FEDERATE_PROVIDERS_LIST + ] ) service = forms.CharField(label=_('service'), widget=forms.HiddenInput(), required=False) method = forms.CharField(widget=forms.HiddenInput(), required=False) diff --git a/cas_server/utils.py b/cas_server/utils.py index 7d418ad..bfa0fe4 100644 --- a/cas_server/utils.py +++ b/cas_server/utils.py @@ -181,3 +181,10 @@ def gen_pgtiou(): def gen_saml_id(): """Generate an saml id""" return _gen_ticket('_') + + +def get_tuple(tuple, index, default=None): + try: + return tuple[index] + except IndexError: + return default From bec51755fa5441b20e96f44ff9f302223c12ae49 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Tue, 21 Jun 2016 15:31:46 +0200 Subject: [PATCH 14/34] fix too many values to unpack in federate.py --- cas_server/federate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cas_server/federate.py b/cas_server/federate.py index 529ddd1..682f35b 100644 --- a/cas_server/federate.py +++ b/cas_server/federate.py @@ -24,7 +24,7 @@ class CASFederateValidateUser(object): self.provider = provider if provider in settings.CAS_FEDERATE_PROVIDERS: - (server_url, version) = settings.CAS_FEDERATE_PROVIDERS[provider] + (server_url, version) = settings.CAS_FEDERATE_PROVIDERS[provider][:2] self.client = CASClient( service_url=service_url, version=version, From 3a71cc2ccac62cc3d8b74a3ecf5844a1d45fe221 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Tue, 21 Jun 2016 15:41:43 +0200 Subject: [PATCH 15/34] Add "remember my identity provider" expiry time in a setting parameter --- README.rst | 3 +++ cas_server/default_settings.py | 1 + cas_server/views.py | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 3cf60fb..e9493e3 100644 --- a/README.rst +++ b/README.rst @@ -146,6 +146,9 @@ Federation settings: The default is ``False``. * ``CAS_FEDERATE_PROVIDERS``: A distionnary for the allowed identity providers (see the federate section below). The default is ``{}``. +* ``CAS_FEDERATE_REMEMBER_TIMEOUT``: Time after witch the cookie use for "remember my identity + provider" expire. The default is ``604800``, one week. The cookie is called + ``_remember_provider``. Tickets validity settings: diff --git a/cas_server/default_settings.py b/cas_server/default_settings.py index 2f32134..b7ee5a9 100644 --- a/cas_server/default_settings.py +++ b/cas_server/default_settings.py @@ -81,6 +81,7 @@ setting_default('CAS_SQL_PASSWORD_CHECK', 'crypt') # crypt or plain setting_default('CAS_FEDERATE', False) # A dict of "provider suffix" -> (provider CAS server url, CAS version, verbose name) setting_default('CAS_FEDERATE_PROVIDERS', {}) +setting_default('CAS_FEDERATE_REMEMBER_TIMEOUT', 604800) # one week if settings.CAS_FEDERATE: settings.CAS_AUTH_CLASS = "cas_server.auth.CASFederateAuth" diff --git a/cas_server/views.py b/cas_server/views.py index 7726eb0..0c2dc73 100644 --- a/cas_server/views.py +++ b/cas_server/views.py @@ -197,7 +197,7 @@ class FederateAuth(View): ) response = HttpResponseRedirect(url) if form.cleaned_data["remember"]: - max_age = 7 * 24 * 60 * 60 # one week + max_age = settings.CAS_FEDERATE_REMEMBER_TIMEOUT utils.set_cookie(response, "_remember_provider", request.POST["provider"], max_age) return response else: From 4c5599ea7b9d3c69e0b53e14107f0db6dd93fe3b Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Tue, 21 Jun 2016 16:19:35 +0200 Subject: [PATCH 16/34] Add CAS_FEDERATE_PROVIDERS_LIST to settings parameters --- README.rst | 6 ++++- cas_server/default_settings.py | 41 ++++++++++++++++++---------------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/README.rst b/README.rst index e9493e3..6357f8e 100644 --- a/README.rst +++ b/README.rst @@ -144,8 +144,12 @@ Federation settings: * ``CAS_FEDERATE``: A boolean for activating the federated mode (see the federate section below). The default is ``False``. -* ``CAS_FEDERATE_PROVIDERS``: A distionnary for the allowed identity providers (see the federate +* ``CAS_FEDERATE_PROVIDERS``: A dictionnary for the allowed identity providers (see the federate section below). The default is ``{}``. +* ``CAS_FEDERATE_PROVIDERS_LIST``: A list in with the keys of ``CAS_FEDERATE_PROVIDERS`` are ordened + for beeing displayed on the login page. The default is the list of all the keys of + ``CAS_FEDERATE_PROVIDERS`` sorted in natural order (0 < 2 < 10 < 20 < a = A < … < z = Z and + lexicographical) * ``CAS_FEDERATE_REMEMBER_TIMEOUT``: Time after witch the cookie use for "remember my identity provider" expire. The default is ``604800``, one week. The cookie is called ``_remember_provider``. diff --git a/cas_server/default_settings.py b/cas_server/default_settings.py index b7ee5a9..0705b19 100644 --- a/cas_server/default_settings.py +++ b/cas_server/default_settings.py @@ -1,3 +1,4 @@ +# ⁻*- 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 @@ -86,24 +87,26 @@ setting_default('CAS_FEDERATE_REMEMBER_TIMEOUT', 604800) # one week if settings.CAS_FEDERATE: settings.CAS_AUTH_CLASS = "cas_server.auth.CASFederateAuth" -__CAS_FEDERATE_PROVIDERS_LIST = list(settings.CAS_FEDERATE_PROVIDERS.keys()) +# create CAS_FEDERATE_PROVIDERS_LIST default value if not set: list of +# the keys of CAS_FEDERATE_PROVIDERS in natural order: 2 < 10 < 20 < a = A < … < z = Z +try: + getattr(settings, 'CAS_FEDERATE_PROVIDERS_LIST') +except AttributeError: + __CAS_FEDERATE_PROVIDERS_LIST = list(settings.CAS_FEDERATE_PROVIDERS.keys()) + def __CAS_FEDERATE_PROVIDERS_LIST_sort(key): + if len(settings.CAS_FEDERATE_PROVIDERS[key]) > 2: + key = settings.CAS_FEDERATE_PROVIDERS[key][2].lower() + else: + key = key.lower() + if isinstance(key, six.string_types) or isinstance(key, six.text_type): + return tuple( + int(num) if num else alpha + for num, alpha in __CAS_FEDERATE_PROVIDERS_LIST_sort.tokenize(key) + ) + else: + return key + __CAS_FEDERATE_PROVIDERS_LIST_sort.tokenize = re.compile(r'(\d+)|(\D+)').findall + __CAS_FEDERATE_PROVIDERS_LIST.sort(key=__CAS_FEDERATE_PROVIDERS_LIST_sort) -def __CAS_FEDERATE_PROVIDERS_LIST_sort(key): - if len(settings.CAS_FEDERATE_PROVIDERS[key]) > 2: - key = settings.CAS_FEDERATE_PROVIDERS[key][2].lower() - else: - key = key.lower() - if isinstance(key, six.string_types) or isinstance(key, six.text_type): - return tuple( - int(num) if num else alpha - for num, alpha in __CAS_FEDERATE_PROVIDERS_LIST_sort.tokenize(key) - ) - else: - return key - - -__CAS_FEDERATE_PROVIDERS_LIST_sort.tokenize = re.compile(r'(\d+)|(\D+)').findall -__CAS_FEDERATE_PROVIDERS_LIST.sort(key=__CAS_FEDERATE_PROVIDERS_LIST_sort) - -settings.CAS_FEDERATE_PROVIDERS_LIST = __CAS_FEDERATE_PROVIDERS_LIST + setting_default('CAS_FEDERATE_PROVIDERS_LIST', __CAS_FEDERATE_PROVIDERS_LIST) From d1c5ff4019f2a3d67a9bb7ba7309957ee61d8deb Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Wed, 22 Jun 2016 12:46:18 +0200 Subject: [PATCH 17/34] Use session to transmist username/ticket from fedeare view to login view Hence, these parameter are not recorder in the user history, and thus the user username do not apear anymore in the history. This respect more the user privacy. --- cas_server/federate.py | 1 - cas_server/views.py | 22 +++++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/cas_server/federate.py b/cas_server/federate.py index 682f35b..64fa96b 100644 --- a/cas_server/federate.py +++ b/cas_server/federate.py @@ -29,7 +29,6 @@ class CASFederateValidateUser(object): service_url=service_url, version=version, server_url=server_url, - extra_login_params={"provider": provider}, renew=False, ) diff --git a/cas_server/views.py b/cas_server/views.py index 0c2dc73..eb0f2d3 100644 --- a/cas_server/views.py +++ b/cas_server/views.py @@ -215,8 +215,9 @@ class FederateAuth(View): else: ticket = request.GET['ticket'] if auth.verify_ticket(ticket): - params = utils.copy_params(request.GET) - params['username'] = "%s@%s" % (auth.username, auth.provider) + params = utils.copy_params(request.GET, ignore={"ticket"}) + request.session["federate_username"] = "%s@%s" % (auth.username, auth.provider) + request.session["federate_ticket"] = ticket url = utils.reverse_params("cas_server:login", params) return HttpResponseRedirect(url) else: @@ -242,6 +243,10 @@ class LoginView(View, LogoutMixin): renewed = False warned = False + if settings.CAS_FEDERATE: + username = None + ticket = None + INVALID_LOGIN_TICKET = 1 USER_LOGIN_OK = 2 USER_LOGIN_FAILURE = 3 @@ -307,7 +312,10 @@ class LoginView(View, LogoutMixin): ) self.user.save() elif ret == self.USER_LOGIN_FAILURE: # bad user login - self.ticket = None + if settings.CAS_FEDERATE: + self.ticket = None + self.usernalme = None + self.init_form() self.logout() elif ret == self.USER_ALREADY_LOGGED: pass @@ -353,8 +361,12 @@ class LoginView(View, LogoutMixin): self.ajax = 'HTTP_X_AJAX' in request.META self.warn = request.GET.get('warn') if settings.CAS_FEDERATE: - self.username = request.GET.get('username') - self.ticket = request.GET.get('ticket') + self.username = request.session.get("federate_username") + self.ticket = request.session.get("federate_ticket") + if self.username: + del request.session["federate_username"] + if self.ticket: + del request.session["federate_ticket"] def get(self, request, *args, **kwargs): """methode called on GET request on this view""" From 7a637c73213388a4fc3b5e05aadc3e6a44f5ac46 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Thu, 23 Jun 2016 12:14:02 +0200 Subject: [PATCH 18/34] verify_ticket(ticket) can return None as attributes --- cas_server/federate.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cas_server/federate.py b/cas_server/federate.py index 64fa96b..98fbe0c 100644 --- a/cas_server/federate.py +++ b/cas_server/federate.py @@ -44,6 +44,8 @@ class CASFederateValidateUser(object): return False username, attributs, pgtiou = self.client.verify_ticket(ticket) if username is not None: + if attributs is None: + attributs = {} attributs["provider"] = self.provider self.username = username self.attributs = attributs From e820a3a57a4c8e092ea758c4ff3299f2ee22586c Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Thu, 23 Jun 2016 12:14:15 +0200 Subject: [PATCH 19/34] Small fixes to cas.py, waiting for upstream merge --- cas_server/cas.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cas_server/cas.py b/cas_server/cas.py index b12359d..c190d48 100644 --- a/cas_server/cas.py +++ b/cas_server/cas.py @@ -134,7 +134,7 @@ class CASClientV1(CASClientBase): Returns username on success and None on failure. """ - params = [('ticket', ticket), ('service', self.service)] + params = [('ticket', ticket), ('service', self.service_url)] url = (urllib_parse.urljoin(self.server_url, 'validate') + '?' + urllib_parse.urlencode(params)) page = urllib_request.urlopen(url) @@ -294,6 +294,9 @@ class CASClientWithSAMLV1(CASClientV2, SingleLogoutMixin): success = tree.find('.//' + SAML_1_0_PROTOCOL_NS + 'StatusCode') if success is not None and success.attrib['Value'].endswith(':Success'): # User is validated + name_identifier = tree.find('.//' + SAML_1_0_ASSERTION_NS + 'NameIdentifier') + if name_identifier is not None: + user = name_identifier.text attrs = tree.findall('.//' + SAML_1_0_ASSERTION_NS + 'Attribute') for at in attrs: if self.username_attribute in list(at.attrib.values()): From 6d7300fe434b059607dd3617a9d3c331e1b2b5d6 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Thu, 23 Jun 2016 17:18:53 +0200 Subject: [PATCH 20/34] Add SLO support from federated CAS --- cas_server/federate.py | 36 ++++++++- .../management/commands/cas_clean_federate.py | 4 +- .../migrations/0006_auto_20160623_1516.py | 28 +++++++ cas_server/models.py | 19 +++++ cas_server/utils.py | 2 + cas_server/views.py | 77 ++++++++++++++----- 6 files changed, 143 insertions(+), 23 deletions(-) create mode 100644 cas_server/migrations/0006_auto_20160623_1516.py diff --git a/cas_server/federate.py b/cas_server/federate.py index 98fbe0c..3aed3ae 100644 --- a/cas_server/federate.py +++ b/cas_server/federate.py @@ -12,7 +12,11 @@ from .default_settings import settings from .cas import CASClient -from .models import FederatedUser +from .models import FederatedUser, FederateSLO, User + +from importlib import import_module + +SessionStore = import_module(settings.SESSION_ENGINE).SessionStore class CASFederateValidateUser(object): @@ -68,3 +72,33 @@ class CASFederateValidateUser(object): return True else: return False + + def register_slo(self, username, session_key, ticket): + FederateSLO.objects.create( + username=username, + session_key=session_key, + ticket=ticket + ) + + def clean_sessions(self, logout_request): + try: + SLOs = self.client.get_saml_slos(logout_request) + except NameError: + SLOs = [] + for slo in SLOs: + try: + for federate_slo in FederateSLO.objects.filter(ticket=slo.text): + session = SessionStore(session_key=federate_slo.session_key) + session.flush() + try: + user = User.objects.get( + username=federate_slo.username, + session_key=federate_slo.session_key + ) + user.logout() + user.delete() + except User.DoesNotExist: + pass + federate_slo.delete() + except FederateSLO.DoesNotExist: + pass diff --git a/cas_server/management/commands/cas_clean_federate.py b/cas_server/management/commands/cas_clean_federate.py index 982982a..04e0608 100644 --- a/cas_server/management/commands/cas_clean_federate.py +++ b/cas_server/management/commands/cas_clean_federate.py @@ -16,6 +16,8 @@ class Command(BaseCommand): federated_users = models.FederatedUser.objects.filter( last_update__lt=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_TIMEOUT)) ) + known_users = {user.username for user in models.User.objects.all()} for user in federated_users: - if not models.User.objects.filter(username='%s@%s' % (user.username, user.provider)): + if not ('%s@%s' % (user.username, user.provider)) in known_users: user.delete() + models.FederateSLO.clean_deleted_sessions() diff --git a/cas_server/migrations/0006_auto_20160623_1516.py b/cas_server/migrations/0006_auto_20160623_1516.py new file mode 100644 index 0000000..6a580c4 --- /dev/null +++ b/cas_server/migrations/0006_auto_20160623_1516.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-06-23 15:16 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cas_server', '0005_auto_20160616_1018'), + ] + + operations = [ + migrations.CreateModel( + name='FederateSLO', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('username', models.CharField(max_length=30)), + ('session_key', models.CharField(blank=True, max_length=40, null=True)), + ('ticket', models.CharField(max_length=255)), + ], + ), + migrations.AlterUniqueTogether( + name='federateslo', + unique_together=set([('username', 'session_key')]), + ), + ] diff --git a/cas_server/models.py b/cas_server/models.py index 40ff687..e2800b9 100644 --- a/cas_server/models.py +++ b/cas_server/models.py @@ -48,6 +48,25 @@ class FederatedUser(models.Model): return u"%s@%s" % (self.username, self.provider) +class FederateSLO(models.Model): + class Meta: + unique_together = ("username", "session_key") + username = models.CharField(max_length=30) + session_key = models.CharField(max_length=40, blank=True, null=True) + ticket = models.CharField(max_length=255) + + @property + def provider(self): + component = self.username.split("@") + return component[-1] + + @classmethod + def clean_deleted_sessions(cls): + for federate_slo in cls.objects.all(): + if not SessionStore(session_key=federate_slo.session_key).get('authenticated'): + federate_slo.delete() + + class User(models.Model): """A user logged into the CAS""" class Meta: diff --git a/cas_server/utils.py b/cas_server/utils.py index bfa0fe4..f274dcd 100644 --- a/cas_server/utils.py +++ b/cas_server/utils.py @@ -184,6 +184,8 @@ def gen_saml_id(): def get_tuple(tuple, index, default=None): + if tuple is None: + return default try: return tuple[index] except IndexError: diff --git a/cas_server/views.py b/cas_server/views.py index eb0f2d3..a6cf5fe 100644 --- a/cas_server/views.py +++ b/cas_server/views.py @@ -20,7 +20,7 @@ from django.utils.decorators import method_decorator from django.utils.translation import ugettext as _ from django.utils import timezone from django.views.decorators.csrf import csrf_exempt - +from django.middleware.csrf import CsrfViewMiddleware from django.views.generic import View import logging @@ -78,6 +78,11 @@ class LogoutMixin(object): username=username, session_key=self.request.session.session_key ) + if settings.CAS_FEDERATE: + models.FederateSLO.objects.filter( + username=username, + session_key=self.request.session.session_key + ).delete() self.request.session.flush() user.logout(self.request) user.delete() @@ -181,43 +186,73 @@ class LogoutView(View, LogoutMixin): class FederateAuth(View): + + @method_decorator(csrf_exempt) + def dispatch(self, request, *args, **kwargs): + return super(FederateAuth, self).dispatch(request, *args, **kwargs) + + def get_cas_client(self, request, provider): + if provider in settings.CAS_FEDERATE_PROVIDERS: + service_url = utils.get_current_url(request, {"ticket", "provider"}) + return CASFederateValidateUser(provider, service_url) + def post(self, request, provider=None): if not settings.CAS_FEDERATE: return redirect("cas_server:login") - form = forms.FederateSelect(request.POST) - if form.is_valid(): - params = utils.copy_params( - request.POST, - ignore={"provider", "csrfmiddlewaretoken", "ticket"} - ) - url = utils.reverse_params( - "cas_server:federateAuth", - kwargs=dict(provider=form.cleaned_data["provider"]), - params=params - ) - response = HttpResponseRedirect(url) - if form.cleaned_data["remember"]: - max_age = settings.CAS_FEDERATE_REMEMBER_TIMEOUT - utils.set_cookie(response, "_remember_provider", request.POST["provider"], max_age) - return response + # POST with a provider, this is probably an SLO request + if provider in settings.CAS_FEDERATE_PROVIDERS: + auth = self.get_cas_client(request, provider) + try: + auth.clean_sessions(request.POST['logoutRequest']) + except KeyError: + pass + return HttpResponse("ok") + # else, a User is trying to log in using an identity provider else: - return redirect("cas_server:login") + # Manually checking for csrf to protect the code below + reason = CsrfViewMiddleware().process_view(request, None, (), {}) + if reason is not None: + return reason # Failed the test, stop here. + form = forms.FederateSelect(request.POST) + if form.is_valid(): + params = utils.copy_params( + request.POST, + ignore={"provider", "csrfmiddlewaretoken", "ticket"} + ) + url = utils.reverse_params( + "cas_server:federateAuth", + kwargs=dict(provider=form.cleaned_data["provider"]), + params=params + ) + response = HttpResponseRedirect(url) + if form.cleaned_data["remember"]: + max_age = settings.CAS_FEDERATE_REMEMBER_TIMEOUT + utils.set_cookie( + response, + "_remember_provider", + request.POST["provider"], + max_age + ) + return response + else: + return redirect("cas_server:login") def get(self, request, provider=None): if not settings.CAS_FEDERATE: return redirect("cas_server:login") if provider not in settings.CAS_FEDERATE_PROVIDERS: return redirect("cas_server:login") - service_url = utils.get_current_url(request, {"ticket", "provider"}) - auth = CASFederateValidateUser(provider, service_url) + auth = self.get_cas_client(request, provider) if 'ticket' not in request.GET: return HttpResponseRedirect(auth.get_login_url()) else: ticket = request.GET['ticket'] if auth.verify_ticket(ticket): params = utils.copy_params(request.GET, ignore={"ticket"}) - request.session["federate_username"] = "%s@%s" % (auth.username, auth.provider) + username = "%s@%s" % (auth.username, auth.provider) + request.session["federate_username"] = username request.session["federate_ticket"] = ticket + auth.register_slo(username, request.session.session_key, ticket) url = utils.reverse_params("cas_server:login", params) return HttpResponseRedirect(url) else: From dedc1e34a2361fcc0c662756a14c709fee1a9abc Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Tue, 28 Jun 2016 00:37:18 +0200 Subject: [PATCH 21/34] Fix some style error introduced during the merge --- cas_server/auth.py | 1 + cas_server/utils.py | 1 + 2 files changed, 2 insertions(+) diff --git a/cas_server/auth.py b/cas_server/auth.py index 231a489..fe259e4 100644 --- a/cas_server/auth.py +++ b/cas_server/auth.py @@ -141,6 +141,7 @@ class DjangoAuthUser(AuthUser): # pragma: no cover else: return {} + class CASFederateAuth(AuthUser): user = None diff --git a/cas_server/utils.py b/cas_server/utils.py index 1314e0e..aa9f8f4 100644 --- a/cas_server/utils.py +++ b/cas_server/utils.py @@ -192,6 +192,7 @@ def get_tuple(tuple, index, default=None): except IndexError: return default + class PGTUrlHandler(BaseHTTPServer.BaseHTTPRequestHandler): PARAMS = {} From 434dcf6e4eae46b29dacd74948db2fc56b060a30 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Tue, 28 Jun 2016 00:48:48 +0200 Subject: [PATCH 22/34] Correct some codacy errors --- cas_server/default_settings.py | 8 ++++---- cas_server/federate.py | 11 ++++++----- cas_server/utils.py | 14 +++++++++----- cas_server/views.py | 3 ++- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/cas_server/default_settings.py b/cas_server/default_settings.py index 750a9c9..a773573 100644 --- a/cas_server/default_settings.py +++ b/cas_server/default_settings.py @@ -105,7 +105,7 @@ try: except AttributeError: __CAS_FEDERATE_PROVIDERS_LIST = list(settings.CAS_FEDERATE_PROVIDERS.keys()) - def __CAS_FEDERATE_PROVIDERS_LIST_sort(key): + def __cas_federate_providers_list_sort(key): if len(settings.CAS_FEDERATE_PROVIDERS[key]) > 2: key = settings.CAS_FEDERATE_PROVIDERS[key][2].lower() else: @@ -113,11 +113,11 @@ except AttributeError: if isinstance(key, six.string_types) or isinstance(key, six.text_type): return tuple( int(num) if num else alpha - for num, alpha in __CAS_FEDERATE_PROVIDERS_LIST_sort.tokenize(key) + for num, alpha in __cas_federate_providers_list_sort.tokenize(key) ) else: return key - __CAS_FEDERATE_PROVIDERS_LIST_sort.tokenize = re.compile(r'(\d+)|(\D+)').findall - __CAS_FEDERATE_PROVIDERS_LIST.sort(key=__CAS_FEDERATE_PROVIDERS_LIST_sort) + __cas_federate_providers_list_sort.tokenize = re.compile(r'(\d+)|(\D+)').findall + __CAS_FEDERATE_PROVIDERS_LIST.sort(key=__cas_federate_providers_list_sort) setting_default('CAS_FEDERATE_PROVIDERS_LIST', __CAS_FEDERATE_PROVIDERS_LIST) diff --git a/cas_server/federate.py b/cas_server/federate.py index 3aed3ae..453a778 100644 --- a/cas_server/federate.py +++ b/cas_server/federate.py @@ -46,7 +46,7 @@ class CASFederateValidateUser(object): """test `password` agains the user""" if self.client is None: return False - username, attributs, pgtiou = self.client.verify_ticket(ticket) + username, attributs = self.client.verify_ticket(ticket)[:2] if username is not None: if attributs is None: attributs = {} @@ -73,7 +73,8 @@ class CASFederateValidateUser(object): else: return False - def register_slo(self, username, session_key, ticket): + @staticmethod + def register_slo(username, session_key, ticket): FederateSLO.objects.create( username=username, session_key=session_key, @@ -82,10 +83,10 @@ class CASFederateValidateUser(object): def clean_sessions(self, logout_request): try: - SLOs = self.client.get_saml_slos(logout_request) + slos = self.client.get_saml_slos(logout_request) except NameError: - SLOs = [] - for slo in SLOs: + slos = [] + for slo in slos: try: for federate_slo in FederateSLO.objects.filter(ticket=slo.text): session = SessionStore(session_key=federate_slo.session_key) diff --git a/cas_server/utils.py b/cas_server/utils.py index aa9f8f4..9487ec8 100644 --- a/cas_server/utils.py +++ b/cas_server/utils.py @@ -73,7 +73,9 @@ def reverse_params(url_name, params=None, **kwargs): return url -def copy_params(get_or_post_params, ignore=set()): +def copy_params(get_or_post_params, ignore=None): + if ignore is None: + ignore = set() params = {} for key in get_or_post_params: if key not in ignore and get_or_post_params[key]: @@ -96,7 +98,9 @@ def set_cookie(response, key, value, max_age): ) -def get_current_url(request, ignore_params=set()): +def get_current_url(request, ignore_params=None): + if ignore_params is None: + ignore_params = set() protocol = 'https' if request.is_secure() else "http" service_url = "%s://%s%s" % (protocol, request.get_host(), request.path) if request.GET: @@ -184,11 +188,11 @@ def gen_saml_id(): return _gen_ticket('_') -def get_tuple(tuple, index, default=None): - if tuple is None: +def get_tuple(nuplet, index, default=None): + if nuplet is None: return default try: - return tuple[index] + return nuplet[index] except IndexError: return default diff --git a/cas_server/views.py b/cas_server/views.py index 0d41620..f1434b0 100644 --- a/cas_server/views.py +++ b/cas_server/views.py @@ -193,7 +193,8 @@ class FederateAuth(View): def dispatch(self, request, *args, **kwargs): return super(FederateAuth, self).dispatch(request, *args, **kwargs) - def get_cas_client(self, request, provider): + @staticmethod + def get_cas_client(request, provider): if provider in settings.CAS_FEDERATE_PROVIDERS: service_url = utils.get_current_url(request, {"ticket", "provider"}) return CASFederateValidateUser(provider, service_url) From 2f1b3862ffe0745faf715d9d6d1cb6dbe8cac609 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sat, 2 Jul 2016 23:24:50 +0200 Subject: [PATCH 23/34] Exclude cas_server/cas.py from coverage as it is an external lib --- .coveragerc | 1 + 1 file changed, 1 insertion(+) diff --git a/.coveragerc b/.coveragerc index 8f6e752..9163f3e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -5,6 +5,7 @@ omit = cas_server/migrations* cas_server/management/* cas_server/tests/* + cas_server/cas.py [report] exclude_lines = From fcd906ca78a7df613f41adc9629433cd15f615c3 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sun, 3 Jul 2016 13:49:31 +0200 Subject: [PATCH 24/34] Tweak the cas client lib to always return unicode hence, the behaviour is consistent between python2 and python3 --- cas_server/cas.py | 94 +++++++++++++++++++++++++++++++---------------- 1 file changed, 62 insertions(+), 32 deletions(-) diff --git a/cas_server/cas.py b/cas_server/cas.py index c190d48..fdc22fb 100644 --- a/cas_server/cas.py +++ b/cas_server/cas.py @@ -21,6 +21,7 @@ # This file is originated from https://github.com/python-cas/python-cas # at commit ec1f2d4779625229398547b9234d0e9e874a2c9a +import six from six.moves.urllib import parse as urllib_parse from six.moves.urllib import request as urllib_request from six.moves.urllib.request import Request @@ -32,6 +33,15 @@ class CASError(ValueError): pass +class ReturnUnicode(object): + @staticmethod + def unicode(string, charset): + if not isinstance(string, six.text_type): + return string.decode(charset) + else: + return string + + class SingleLogoutMixin(object): @classmethod def get_saml_slos(cls, logout_request): @@ -124,7 +134,7 @@ class CASClientBase(object): raise CASError("Bad http code %s" % response.code) -class CASClientV1(CASClientBase): +class CASClientV1(CASClientBase, ReturnUnicode): """CAS Client Version 1""" logout_redirect_param_name = 'url' @@ -140,15 +150,21 @@ class CASClientV1(CASClientBase): page = urllib_request.urlopen(url) try: verified = page.readline().strip() - if verified == 'yes': - return page.readline().strip(), None, None + if verified == b'yes': + content_type = page.info().get('Content-type') + if "charset=" in content_type: + charset = content_type.split("charset=")[-1] + else: + charset = "ascii" + user = self.unicode(page.readline().strip(), charset) + return user, None, None else: return None, None, None finally: page.close() -class CASClientV2(CASClientBase): +class CASClientV2(CASClientBase, ReturnUnicode): """CAS Client Version 2""" url_suffix = 'serviceValidate' @@ -161,8 +177,8 @@ class CASClientV2(CASClientBase): def verify_ticket(self, ticket): """Verifies CAS 2.0+/3.0+ XML-based authentication ticket and returns extended attributes""" - response = self.get_verification_response(ticket) - return self.verify_response(response) + (response, charset) = self.get_verification_response(ticket) + return self.verify_response(response, charset) def get_verification_response(self, ticket): params = [('ticket', ticket), ('service', self.service_url)] @@ -172,37 +188,42 @@ class CASClientV2(CASClientBase): url = base_url + '?' + urllib_parse.urlencode(params) page = urllib_request.urlopen(url) try: - return page.read() + content_type = page.info().get('Content-type') + if "charset=" in content_type: + charset = content_type.split("charset=")[-1] + else: + charset = "ascii" + return (page.read(), charset) finally: page.close() @classmethod - def parse_attributes_xml_element(cls, element): + def parse_attributes_xml_element(cls, element, charset): attributes = dict() for attribute in element: - tag = attribute.tag.split("}").pop() + tag = cls.self.unicode(attribute.tag, charset).split(u"}").pop() if tag in attributes: if isinstance(attributes[tag], list): - attributes[tag].append(attribute.text) + attributes[tag].append(cls.unicode(attribute.text, charset)) else: attributes[tag] = [attributes[tag]] - attributes[tag].append(attribute.text) + attributes[tag].append(cls.unicode(attribute.text, charset)) else: - if tag == 'attraStyle': + if tag == u'attraStyle': pass else: - attributes[tag] = attribute.text + attributes[tag] = cls.unicode(attribute.text, charset) return attributes @classmethod - def verify_response(cls, response): - user, attributes, pgtiou = cls.parse_response_xml(response) + def verify_response(cls, response, charset): + user, attributes, pgtiou = cls.parse_response_xml(response, charset) if len(attributes) == 0: attributes = None return user, attributes, pgtiou @classmethod - def parse_response_xml(cls, response): + def parse_response_xml(cls, response, charset): try: from xml.etree import ElementTree except ImportError: @@ -216,11 +237,11 @@ class CASClientV2(CASClientBase): if tree[0].tag.endswith('authenticationSuccess'): for element in tree[0]: if element.tag.endswith('user'): - user = element.text + user = cls.unicode(element.text, charset) elif element.tag.endswith('proxyGrantingTicket'): - pgtiou = element.text + pgtiou = cls.unicode(element.text, charset) elif element.tag.endswith('attributes'): - attributes = cls.parse_attributes_xml_element(element) + attributes = cls.parse_attributes_xml_element(element, charset) return user, attributes, pgtiou @@ -230,23 +251,23 @@ class CASClientV3(CASClientV2, SingleLogoutMixin): logout_redirect_param_name = 'service' @classmethod - def parse_attributes_xml_element(cls, element): + def parse_attributes_xml_element(cls, element, charset): attributes = dict() for attribute in element: - tag = attribute.tag.split("}").pop() + tag = cls.unicode(attribute.tag, charset).split(u"}").pop() if tag in attributes: if isinstance(attributes[tag], list): - attributes[tag].append(attribute.text) + attributes[tag].append(cls.unicode(attribute.text, charset)) else: attributes[tag] = [attributes[tag]] - attributes[tag].append(attribute.text) + attributes[tag].append(cls.unicode(attribute.text, charset)) else: - attributes[tag] = attribute.text + attributes[tag] = cls.unicode(attribute.text, charset) return attributes @classmethod - def verify_response(cls, response): - return cls.parse_response_xml(response) + def verify_response(cls, response, charset): + return cls.parse_response_xml(response, charset) SAML_1_0_NS = 'urn:oasis:names:tc:SAML:1.0:' @@ -284,6 +305,11 @@ class CASClientWithSAMLV1(CASClientV2, SingleLogoutMixin): from elementtree import ElementTree page = self.fetch_saml_validation(ticket) + content_type = page.info().get('Content-type') + if "charset=" in content_type: + charset = content_type.split("charset=")[-1] + else: + charset = "ascii" try: user = None @@ -296,21 +322,25 @@ class CASClientWithSAMLV1(CASClientV2, SingleLogoutMixin): # User is validated name_identifier = tree.find('.//' + SAML_1_0_ASSERTION_NS + 'NameIdentifier') if name_identifier is not None: - user = name_identifier.text + user = self.unicode(name_identifier.text, charset) attrs = tree.findall('.//' + SAML_1_0_ASSERTION_NS + 'Attribute') for at in attrs: if self.username_attribute in list(at.attrib.values()): - user = at.find(SAML_1_0_ASSERTION_NS + 'AttributeValue').text - attributes['uid'] = user + user = self.unicode( + at.find(SAML_1_0_ASSERTION_NS + 'AttributeValue').text, + charset + ) + attributes[u'uid'] = user values = at.findall(SAML_1_0_ASSERTION_NS + 'AttributeValue') + key = self.unicode(at.attrib['AttributeName'], charset) if len(values) > 1: values_array = [] for v in values: - values_array.append(v.text) - attributes[at.attrib['AttributeName']] = values_array + values_array.append(self.unicode(v.text, charset)) + attributes[key] = values_array else: - attributes[at.attrib['AttributeName']] = values[0].text + attributes[key] = self.unicode(values[0].text, charset) return user, attributes, None finally: page.close() From 90daf3d2a0d1639906d0da0ea13ddefddcc967da Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sun, 3 Jul 2016 13:51:00 +0200 Subject: [PATCH 25/34] Add unit tests for when CAS_FEDERATE is True Also fix some unicode related bugs --- cas_server/auth.py | 2 +- cas_server/default_settings.py | 12 +- cas_server/federate.py | 43 +-- cas_server/forms.py | 10 +- .../management/commands/cas_clean_federate.py | 12 +- cas_server/models.py | 23 +- cas_server/tests/mixin.py | 47 +++ cas_server/tests/test_federate.py | 344 ++++++++++++++++++ cas_server/tests/test_models.py | 65 +++- cas_server/tests/test_utils.py | 21 +- cas_server/tests/test_view.py | 46 +-- cas_server/tests/utils.py | 163 ++++++++- cas_server/views.py | 105 +++--- 13 files changed, 749 insertions(+), 144 deletions(-) create mode 100644 cas_server/tests/test_federate.py diff --git a/cas_server/auth.py b/cas_server/auth.py index afcb722..d666ec5 100644 --- a/cas_server/auth.py +++ b/cas_server/auth.py @@ -171,7 +171,7 @@ class CASFederateAuth(AuthUser): def attributs(self): """return a dict of user attributes""" - if not self.user: + if not self.user: # pragma: no cover (should not happen) return {} else: return self.user.attributs diff --git a/cas_server/default_settings.py b/cas_server/default_settings.py index 07b420e..be3f064 100644 --- a/cas_server/default_settings.py +++ b/cas_server/default_settings.py @@ -14,7 +14,6 @@ from django.conf import settings from django.contrib.staticfiles.templatetags.staticfiles import static import re -import six def setting_default(name, default_value): @@ -112,13 +111,10 @@ except AttributeError: key = settings.CAS_FEDERATE_PROVIDERS[key][2].lower() else: key = key.lower() - if isinstance(key, six.string_types) or isinstance(key, six.text_type): - return tuple( - int(num) if num else alpha - for num, alpha in __cas_federate_providers_list_sort.tokenize(key) - ) - else: - return key + return tuple( + int(num) if num else alpha + for num, alpha in __cas_federate_providers_list_sort.tokenize(key) + ) __cas_federate_providers_list_sort.tokenize = re.compile(r'(\d+)|(\D+)').findall __CAS_FEDERATE_PROVIDERS_LIST.sort(key=__cas_federate_providers_list_sort) diff --git a/cas_server/federate.py b/cas_server/federate.py index 453a778..2f6489a 100644 --- a/cas_server/federate.py +++ b/cas_server/federate.py @@ -15,6 +15,7 @@ from .cas import CASClient from .models import FederatedUser, FederateSLO, User from importlib import import_module +from six.moves import urllib SessionStore = import_module(settings.SESSION_ENGINE).SessionStore @@ -27,7 +28,7 @@ class CASFederateValidateUser(object): def __init__(self, provider, service_url): self.provider = provider - if provider in settings.CAS_FEDERATE_PROVIDERS: + if provider in settings.CAS_FEDERATE_PROVIDERS: # pragma: no branch (should always be True) (server_url, version) = settings.CAS_FEDERATE_PROVIDERS[provider][:2] self.client = CASClient( service_url=service_url, @@ -44,9 +45,12 @@ class CASFederateValidateUser(object): def verify_ticket(self, ticket): """test `password` agains the user""" - if self.client is None: + if self.client is None: # pragma: no cover (should not happen) + return False + try: + username, attributs = self.client.verify_ticket(ticket)[:2] + except urllib.error.URLError: return False - username, attributs = self.client.verify_ticket(ticket)[:2] if username is not None: if attributs is None: attributs = {} @@ -83,23 +87,20 @@ class CASFederateValidateUser(object): def clean_sessions(self, logout_request): try: - slos = self.client.get_saml_slos(logout_request) - except NameError: + slos = self.client.get_saml_slos(logout_request) or [] + except NameError: # pragma: no cover (should not happen) slos = [] for slo in slos: - try: - for federate_slo in FederateSLO.objects.filter(ticket=slo.text): - session = SessionStore(session_key=federate_slo.session_key) - session.flush() - try: - user = User.objects.get( - username=federate_slo.username, - session_key=federate_slo.session_key - ) - user.logout() - user.delete() - except User.DoesNotExist: - pass - federate_slo.delete() - except FederateSLO.DoesNotExist: - pass + for federate_slo in FederateSLO.objects.filter(ticket=slo.text): + session = SessionStore(session_key=federate_slo.session_key) + session.flush() + try: + user = User.objects.get( + username=federate_slo.username, + session_key=federate_slo.session_key + ) + user.logout() + user.delete() + except User.DoesNotExist: # pragma: no cover (should not happen) + pass + federate_slo.delete() diff --git a/cas_server/forms.py b/cas_server/forms.py index b5cf4d0..dc0e866 100644 --- a/cas_server/forms.py +++ b/cas_server/forms.py @@ -31,6 +31,8 @@ class WarnForm(forms.Form): class FederateSelect(forms.Form): provider = forms.ChoiceField( label=_('Identity provider'), + # with use a lambda abstraction to delay the access to settings.CAS_FEDERATE_PROVIDERS + # this is usefull to use the override_settings decorator in tests choices=[ ( p, @@ -88,8 +90,12 @@ class FederateUserCredential(UserCredential): user = models.FederatedUser.objects.get(username=username, provider=provider) user.ticket = "" user.save() - except models.FederatedUser.DoesNotExist: - raise + # should not happed as is the FederatedUser do not exists, super should + # raise before a ValidationError("bad user") + except models.FederatedUser.DoesNotExist: # pragma: no cover (should not happend) + raise forms.ValidationError( + _(u"User not found in the temporary database, please try to reconnect") + ) return cleaned_data diff --git a/cas_server/management/commands/cas_clean_federate.py b/cas_server/management/commands/cas_clean_federate.py index 04e0608..8d91935 100644 --- a/cas_server/management/commands/cas_clean_federate.py +++ b/cas_server/management/commands/cas_clean_federate.py @@ -1,11 +1,7 @@ from django.core.management.base import BaseCommand from django.utils.translation import ugettext_lazy as _ -from django.utils import timezone - -from datetime import timedelta from ... import models -from ...default_settings import settings class Command(BaseCommand): @@ -13,11 +9,5 @@ class Command(BaseCommand): help = _(u"Clean old federated users") def handle(self, *args, **options): - federated_users = models.FederatedUser.objects.filter( - last_update__lt=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_TIMEOUT)) - ) - known_users = {user.username for user in models.User.objects.all()} - for user in federated_users: - if not ('%s@%s' % (user.username, user.provider)) in known_users: - user.delete() + models.FederatedUser.clean_old_entries() models.FederateSLO.clean_deleted_sessions() diff --git a/cas_server/models.py b/cas_server/models.py index aea270b..3d1f17f 100644 --- a/cas_server/models.py +++ b/cas_server/models.py @@ -46,6 +46,16 @@ class FederatedUser(models.Model): def __unicode__(self): return u"%s@%s" % (self.username, self.provider) + @classmethod + def clean_old_entries(cls): + federated_users = cls.objects.filter( + last_update__lt=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_TIMEOUT)) + ) + known_users = {user.username for user in User.objects.all()} + for user in federated_users: + if not ('%s@%s' % (user.username, user.provider)) in known_users: + user.delete() + class FederateSLO(models.Model): class Meta: @@ -54,11 +64,6 @@ class FederateSLO(models.Model): session_key = models.CharField(max_length=40, blank=True, null=True) ticket = models.CharField(max_length=255) - @property - def provider(self): - component = self.username.split("@") - return component[-1] - @classmethod def clean_deleted_sessions(cls): for federate_slo in cls.objects.all(): @@ -76,6 +81,14 @@ class User(models.Model): username = models.CharField(max_length=30) date = models.DateTimeField(auto_now=True) + def delete(self, *args, **kwargs): + if settings.CAS_FEDERATE: + FederateSLO.objects.filter( + username=self.username, + session_key=self.session_key + ).delete() + super(User, self).delete(*args, **kwargs) + @classmethod def clean_old_entries(cls): """Remove users inactive since more that SESSION_COOKIE_AGE""" diff --git a/cas_server/tests/mixin.py b/cas_server/tests/mixin.py index ddbf2d2..4612fd2 100644 --- a/cas_server/tests/mixin.py +++ b/cas_server/tests/mixin.py @@ -191,3 +191,50 @@ class UserModels(object): username=settings.CAS_TEST_USER, session_key=client.session.session_key ) + + +class CanLogin(object): + """Assertion about login""" + def assert_logged( + self, client, response, warn=False, + code=200, username=settings.CAS_TEST_USER + ): + """Assertions testing that client is well authenticated""" + self.assertEqual(response.status_code, code) + # this message is displayed to the user upon successful authentication + self.assertIn( + ( + b"You have successfully logged into " + b"the Central Authentication Service" + ), + response.content + ) + # these session variables a set if usccessfully authenticated + self.assertEqual(client.session["username"], username) + self.assertIs(client.session["warn"], warn) + self.assertIs(client.session["authenticated"], True) + + # on successfull authentication, a corresponding user object is created + self.assertTrue( + models.User.objects.get( + username=username, + session_key=client.session.session_key + ) + ) + + def assert_login_failed(self, client, response, code=200): + """Assertions testing a failed login attempt""" + self.assertEqual(response.status_code, code) + # this message is displayed to the user upon successful authentication, so it should not + # appear + self.assertFalse( + ( + b"You have successfully logged into " + b"the Central Authentication Service" + ) in response.content + ) + + # if authentication has failed, these session variables should not be set + self.assertTrue(client.session.get("username") is None) + self.assertTrue(client.session.get("warn") is None) + self.assertTrue(client.session.get("authenticated") is None) diff --git a/cas_server/tests/test_federate.py b/cas_server/tests/test_federate.py new file mode 100644 index 0000000..b4e76b2 --- /dev/null +++ b/cas_server/tests/test_federate.py @@ -0,0 +1,344 @@ +# -*- 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 CAS federate mode""" +from cas_server import default_settings +from cas_server.default_settings import settings + +import django +from django.test import TestCase, Client +from django.test.utils import override_settings + +from six.moves import reload_module + +from cas_server import utils, forms +from cas_server.tests.mixin import BaseServicePattern, CanLogin +from cas_server.tests import utils as tests_utils + +PROVIDERS = { + "example.com": ("http://127.0.0.1:8080", 1, "Example dot com"), + "example.org": ("http://127.0.0.1:8081", 2, "Example dot org"), + "example.net": ("http://127.0.0.1:8082", 3, "Example dot net"), + "example.test": ("http://127.0.0.1:8083", 'CAS_2_SAML_1_0'), +} + +PROVIDERS_LIST = list(PROVIDERS.keys()) +PROVIDERS_LIST.sort() + + +def getaddrinfo_mock(name, port, *args, **kwargs): + return [(2, 1, 6, '', ('127.0.0.1', 80))] + + +@override_settings( + CAS_FEDERATE=True, + CAS_FEDERATE_PROVIDERS=PROVIDERS, + CAS_FEDERATE_PROVIDERS_LIST=PROVIDERS_LIST, + CAS_AUTH_CLASS="cas_server.auth.CASFederateAuth", + # test with a non ascii username + CAS_TEST_USER=u"dédé" +) +class FederateAuthLoginLogoutTestCase(TestCase, BaseServicePattern, CanLogin): + """tests for the views login logout and federate then the federated mode is enabled""" + def setUp(self): + """Prepare the test context""" + self.setup_service_patterns() + reload_module(forms) + + def test_default_settings(self): + """default settings should populated some default variable then CAS_FEDERATE is True""" + provider_list = settings.CAS_FEDERATE_PROVIDERS_LIST + del settings.CAS_FEDERATE_PROVIDERS_LIST + del settings.CAS_AUTH_CLASS + reload_module(default_settings) + self.assertEqual(settings.CAS_FEDERATE_PROVIDERS_LIST, provider_list) + self.assertEqual(settings.CAS_AUTH_CLASS, "cas_server.auth.CASFederateAuth") + + def test_login_get_provider(self): + """some assertion about the login page in federated mode""" + client = Client() + response = client.get("/login") + self.assertEqual(response.status_code, 200) + for key, value in settings.CAS_FEDERATE_PROVIDERS.items(): + self.assertTrue('' % ( + key, + utils.get_tuple(value, 2, key) + ) in response.content.decode("utf-8")) + self.assertEqual(response.context['post_url'], '/federate') + + def test_login_post_provider(self, remember=False): + """test a successful login wrokflow""" + tickets = [] + # choose the example.com provider + for (provider, cas_port) in [ + ("example.com", 8080), ("example.org", 8081), + ("example.net", 8082), ("example.test", 8083) + ]: + # get a bare client + client = Client() + # fetch the login page + response = client.get("/login") + # in federated mode, we shoudl POST do /federate on the login page + self.assertEqual(response.context['post_url'], '/federate') + # get current form parameter + params = tests_utils.copy_form(response.context["form"]) + params['provider'] = provider + if remember: + params['remember'] = 'on' + # post the choosed provider + response = client.post('/federate', params) + # we are redirected to the provider CAS client url + self.assertEqual(response.status_code, 302) + if remember: + self.assertEqual(response["Location"], '%s/federate/%s?remember=on' % ( + 'http://testserver' if django.VERSION < (1, 9) else "", + provider + )) + else: + self.assertEqual(response["Location"], '%s/federate/%s' % ( + 'http://testserver' if django.VERSION < (1, 9) else "", + provider + )) + # let's follow the redirect + response = client.get('/federate/%s' % provider) + # we are redirected to the provider CAS for authentication + self.assertEqual(response.status_code, 302) + self.assertEqual( + response["Location"], + "%s/login?service=http%%3A%%2F%%2Ftestserver%%2Ffederate%%2F%s" % ( + settings.CAS_FEDERATE_PROVIDERS[provider][0], + provider + ) + ) + # let's generate a ticket + ticket = utils.gen_st() + # we lauch a dummy CAS server that only validate once for the service + # http://testserver/federate/example.com with `ticket` + tests_utils.DummyCAS.run( + ("http://testserver/federate/%s" % provider).encode("ascii"), + ticket.encode("ascii"), + settings.CAS_TEST_USER.encode("utf8"), + [], + cas_port + ) + # we normally provide a good ticket and should be redirected to /login as the ticket + # get successfully validated again the dummy CAS + response = client.get('/federate/%s' % provider, {'ticket': ticket}) + self.assertEqual(response.status_code, 302) + self.assertEqual(response["Location"], "%s/login" % ( + 'http://testserver' if django.VERSION < (1, 9) else "" + )) + # follow the redirect + response = client.get("/login") + # we should get a page with a from with all widget hidden that auto POST to /login using + # javascript. If javascript is disabled, a "connect" button is showed + self.assertTrue(response.context['auto_submit']) + self.assertEqual(response.context['post_url'], '/login') + params = tests_utils.copy_form(response.context["form"]) + # POST ge prefiled from parameters + response = client.post("/login", params) + # the user should now being authenticated using username test@`provider` + self.assert_logged( + client, response, username='%s@%s' % (settings.CAS_TEST_USER, provider) + ) + tickets.append((provider, ticket, client)) + + # try to get a ticket + response = client.get("/login", {'service': self.service}) + self.assertEqual(response.status_code, 302) + self.assertTrue(response["Location"].startswith("%s?ticket=" % self.service)) + return tickets + + def test_login_twice(self): + """Test that user id db is used for the second login (cf coverage)""" + self.test_login_post_provider() + self.test_login_post_provider() + + @override_settings(CAS_FEDERATE=False) + def test_auth_federate_false(self): + """federated view should redirect to /login then CAS_FEDERATE is False""" + provider = "example.com" + client = Client() + response = client.get("/federate/%s" % provider) + self.assertEqual(response.status_code, 302) + self.assertEqual(response["Location"], "%s/login" % ( + 'http://testserver' if django.VERSION < (1, 9) else "" + )) + response = client.post("%s/federate/%s" % ( + 'http://testserver' if django.VERSION < (1, 9) else "", + provider + )) + self.assertEqual(response.status_code, 302) + self.assertEqual(response["Location"], "%s/login" % ( + 'http://testserver' if django.VERSION < (1, 9) else "" + )) + + def test_auth_federate_errors(self): + """ + The federated view should redirect to /login if the provider is unknown or not provided, + try to fetch a new ticket if the provided ticket validation fail + (network error or bad ticket) + """ + return + good_provider = "example.com" + bad_provider = "exemple.fr" + client = Client() + response = client.get("/federate/%s" % bad_provider) + self.assertEqual(response.status_code, 302) + self.assertEqual(response["Location"], "%s/login" % ( + 'http://testserver' if django.VERSION < (1, 9) else "" + )) + + # test CAS not avaible + response = client.get("/federate/%s" % good_provider, {'ticket': utils.gen_st()}) + self.assertEqual(response.status_code, 302) + self.assertEqual( + response["Location"], + "%s/login?service=http%%3A%%2F%%2Ftestserver%%2Ffederate%%2F%s" % ( + settings.CAS_FEDERATE_PROVIDERS[good_provider][0], + good_provider + ) + ) + + # test CAS avaible but bad ticket + tests_utils.DummyCAS.run( + ("http://testserver/federate/%s" % good_provider).encode("ascii"), + utils.gen_st().encode("ascii"), + settings.CAS_TEST_USER.encode("utf-8"), + [], + 8080 + ) + response = client.get("/federate/%s" % good_provider, {'ticket': utils.gen_st()}) + self.assertEqual(response.status_code, 302) + self.assertEqual( + response["Location"], + "%s/login?service=http%%3A%%2F%%2Ftestserver%%2Ffederate%%2F%s" % ( + settings.CAS_FEDERATE_PROVIDERS[good_provider][0], + good_provider + ) + ) + + response = client.post("/federate") + self.assertEqual(response.status_code, 302) + self.assertEqual(response["Location"], "%s/login" % ( + 'http://testserver' if django.VERSION < (1, 9) else "" + )) + + def test_auth_federate_slo(self): + """test that SLO receive from backend CAS log out the users""" + # get tickets and connected clients + tickets = self.test_login_post_provider() + for (provider, ticket, client) in tickets: + # SLO for an unkown ticket should do nothing + response = client.post( + "/federate/%s" % provider, + {'logoutRequest': tests_utils.logout_request(utils.gen_st())} + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b"ok") + # Bad SLO format should do nothing + response = client.post( + "/federate/%s" % provider, + {'logoutRequest': ""} + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b"ok") + # Bad SLO format should do nothing + response = client.post( + "/federate/%s" % provider, + {'logoutRequest': ""} + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b"ok") + response = client.get("/login") + self.assert_logged( + client, response, username='%s@%s' % (settings.CAS_TEST_USER, provider) + ) + + # SLO for a previously logged ticket should log out the user if CAS version is + # 3 or 'CAS_2_SAML_1_0' + response = client.post( + "/federate/%s" % provider, + {'logoutRequest': tests_utils.logout_request(ticket)} + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b"ok") + + response = client.get("/login") + if settings.CAS_FEDERATE_PROVIDERS[provider][1] in {3, 'CAS_2_SAML_1_0'}: # support SLO + self.assert_login_failed(client, response) + else: + self.assert_logged( + client, response, username='%s@%s' % (settings.CAS_TEST_USER, provider) + ) + + def test_federate_logout(self): + """ + test the logout function: the user should be log out + and redirected to his CAS logout page + """ + # get tickets and connected clients + tickets = self.test_login_post_provider() + for (provider, _, client) in tickets: + response = client.get("/logout") + self.assertEqual(response.status_code, 302) + self.assertEqual( + response["Location"], + "%s/logout" % settings.CAS_FEDERATE_PROVIDERS[provider][0] + ) + response = client.get("/login") + self.assert_login_failed(client, response) + + def test_remember_provider(self): + """ + If the user check remember, next login should not offer the chose of the backend CAS + and use the one store in the cookie + """ + tickets = self.test_login_post_provider(remember=True) + for (provider, _, client) in tickets: + client.get("/logout") + response = client.get("/login") + self.assertEqual(response.status_code, 302) + self.assertEqual(response["Location"], "%s/federate/%s" % ( + 'http://testserver' if django.VERSION < (1, 9) else "", + provider + )) + + def test_login_bad_ticket(self): + """ + Try login with a bad ticket: + login should fail and the main login page should be displayed to the user + """ + provider = "example.com" + # get a bare client + client = Client() + session = client.session + session["federate_username"] = '%s@%s' % (settings.CAS_TEST_USER, provider) + session["federate_ticket"] = utils.gen_st() + try: + session.save() + response = client.get("/login") + # we should get a page with a from with all widget hidden that auto POST to /login using + # javascript. If javascript is disabled, a "connect" button is showed + self.assertTrue(response.context['auto_submit']) + self.assertEqual(response.context['post_url'], '/login') + params = tests_utils.copy_form(response.context["form"]) + # POST, as (username, ticket) are not valid, we should get the federate login page + response = client.post("/login", params) + self.assertEqual(response.status_code, 200) + for key, value in settings.CAS_FEDERATE_PROVIDERS.items(): + self.assertTrue('' % ( + key, + utils.get_tuple(value, 2, key) + ) in response.content.decode("utf-8")) + self.assertEqual(response.context['post_url'], '/federate') + except AttributeError: + pass diff --git a/cas_server/tests/test_models.py b/cas_server/tests/test_models.py index e75f54f..cdaece8 100644 --- a/cas_server/tests/test_models.py +++ b/cas_server/tests/test_models.py @@ -12,20 +12,81 @@ """Tests module for models""" from cas_server.default_settings import settings -from django.test import TestCase +from django.test import TestCase, Client from django.test.utils import override_settings from django.utils import timezone from datetime import timedelta from importlib import import_module -from cas_server import models +from cas_server import models, utils from cas_server.tests.utils import get_auth_client, HttpParamsHandler from cas_server.tests.mixin import UserModels, BaseServicePattern SessionStore = import_module(settings.SESSION_ENGINE).SessionStore +class FederatedUserTestCase(TestCase, UserModels): + """test for the federated user model""" + def test_clean_old_entries(self): + """tests for clean_old_entries that should delete federated user no longer used""" + client = Client() + client.get("/login") + models.FederatedUser.objects.create( + username="test1", provider="example.com", attributs={}, ticket="" + ) + models.FederatedUser.objects.create( + username="test2", provider="example.com", attributs={}, ticket="" + ) + models.FederatedUser.objects.all().update( + last_update=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_TIMEOUT + 10)) + ) + models.FederatedUser.objects.create( + username="test3", provider="example.com", attributs={}, ticket="" + ) + models.User.objects.create( + username="test1@example.com", session_key=client.session.session_key + ) + models.FederatedUser.clean_old_entries() + self.assertEqual(len(models.FederatedUser.objects.all()), 2) + with self.assertRaises(models.FederatedUser.DoesNotExist): + models.FederatedUser.objects.get(username="test2") + + +class FederateSLOTestCase(TestCase, UserModels): + """test for the federated SLO model""" + def test_clean_deleted_sessions(self): + """ + tests for clean_deleted_sessions that should delete object for which matching session + do not exists anymore + """ + client1 = Client() + client2 = Client() + client1.get("/login") + client2.get("/login") + session = client2.session + session['authenticated'] = True + try: + session.save() + except AttributeError: + pass + models.FederateSLO.objects.create( + username="test1@example.com", + session_key=client1.session.session_key, + ticket=utils.gen_st() + ) + models.FederateSLO.objects.create( + username="test2@example.com", + session_key=client2.session.session_key, + ticket=utils.gen_st() + ) + self.assertEqual(len(models.FederateSLO.objects.all()), 2) + models.FederateSLO.clean_deleted_sessions() + self.assertEqual(len(models.FederateSLO.objects.all()), 1) + with self.assertRaises(models.FederateSLO.DoesNotExist): + models.FederateSLO.objects.get(username="test1@example.com") + + @override_settings(CAS_AUTH_CLASS='cas_server.auth.TestAuthUser') class UserTestCase(TestCase, UserModels): """tests for the user models""" diff --git a/cas_server/tests/test_utils.py b/cas_server/tests/test_utils.py index 76fa2cc..411848a 100644 --- a/cas_server/tests/test_utils.py +++ b/cas_server/tests/test_utils.py @@ -10,7 +10,7 @@ # # (c) 2016 Valentin Samir """Tests module for utils""" -from django.test import TestCase +from django.test import TestCase, RequestFactory import six @@ -189,3 +189,22 @@ class UtilsTestCase(TestCase): self.assertFalse(utils.crypt_salt_is_valid("$$")) # start with $ followed by $ self.assertFalse(utils.crypt_salt_is_valid("$toto")) # start with $ but no secondary $ self.assertFalse(utils.crypt_salt_is_valid("$toto$toto")) # algorithm toto not known + + def test_get_current_url(self): + """test the function get_current_url""" + factory = RequestFactory() + request = factory.get('/truc/muche?test=1') + self.assertEqual(utils.get_current_url(request), 'http://testserver/truc/muche?test=1') + self.assertEqual( + utils.get_current_url(request, ignore_params={'test'}), + 'http://testserver/truc/muche' + ) + + def test_get_tuple(self): + """test the function get_tuple""" + test_tuple = (1, 2, 3) + for index, value in enumerate(test_tuple): + self.assertEqual(utils.get_tuple(test_tuple, index), value) + self.assertEqual(utils.get_tuple(test_tuple, 3), None) + self.assertEqual(utils.get_tuple(test_tuple, 3, 'toto'), 'toto') + self.assertEqual(utils.get_tuple(None, 3), None) diff --git a/cas_server/tests/test_view.py b/cas_server/tests/test_view.py index 0acd52f..49fa2d2 100644 --- a/cas_server/tests/test_view.py +++ b/cas_server/tests/test_view.py @@ -1,4 +1,4 @@ -# ⁻*- coding: utf-8 -*- +# -*- 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 @@ -36,57 +36,17 @@ from cas_server.tests.utils import ( HttpParamsHandler, Http404Handler ) -from cas_server.tests.mixin import BaseServicePattern, XmlContent +from cas_server.tests.mixin import BaseServicePattern, XmlContent, CanLogin @override_settings(CAS_AUTH_CLASS='cas_server.auth.TestAuthUser') -class LoginTestCase(TestCase, BaseServicePattern): +class LoginTestCase(TestCase, BaseServicePattern, CanLogin): """Tests for the login view""" def setUp(self): """Prepare the test context:""" # we prepare a bunch a service url and service patterns for tests self.setup_service_patterns() - def assert_logged(self, client, response, warn=False, code=200): - """Assertions testing that client is well authenticated""" - self.assertEqual(response.status_code, code) - # this message is displayed to the user upon successful authentication - self.assertTrue( - ( - b"You have successfully logged into " - b"the Central Authentication Service" - ) in response.content - ) - # these session variables a set if usccessfully authenticated - self.assertTrue(client.session["username"] == settings.CAS_TEST_USER) - self.assertTrue(client.session["warn"] is warn) - self.assertTrue(client.session["authenticated"] is True) - - # on successfull authentication, a corresponding user object is created - self.assertTrue( - models.User.objects.get( - username=settings.CAS_TEST_USER, - session_key=client.session.session_key - ) - ) - - def assert_login_failed(self, client, response, code=200): - """Assertions testing a failed login attempt""" - self.assertEqual(response.status_code, code) - # this message is displayed to the user upon successful authentication, so it should not - # appear - self.assertFalse( - ( - b"You have successfully logged into " - b"the Central Authentication Service" - ) in response.content - ) - - # if authentication has failed, these session variables should not be set - self.assertTrue(client.session.get("username") is None) - self.assertTrue(client.session.get("warn") is None) - self.assertTrue(client.session.get("authenticated") is None) - def test_login_view_post_goodpass_goodlt(self): """Test a successul login""" # we get a client who fetch a frist time the login page and the login form default diff --git a/cas_server/tests/utils.py b/cas_server/tests/utils.py index bd692e9..b8419c6 100644 --- a/cas_server/tests/utils.py +++ b/cas_server/tests/utils.py @@ -13,14 +13,33 @@ from cas_server.default_settings import settings from django.test import Client +from django.template import loader, Context +from django.utils import timezone import cgi +import six from threading import Thread from lxml import etree from six.moves import BaseHTTPServer from six.moves.urllib.parse import urlparse, parse_qsl +from datetime import timedelta from cas_server import models +from cas_server import utils + + +def return_unicode(string, charset): + if not isinstance(string, six.text_type): + return string.decode(charset) + else: + return string + + +def return_bytes(string, charset): + if isinstance(string, six.text_type): + return string.encode(charset) + else: + return string def copy_form(form): @@ -149,10 +168,10 @@ class HttpParamsHandler(BaseHTTPServer.BaseHTTPRequestHandler): return @classmethod - def run(cls): + def run(cls, port=0): """Run a BaseHTTPServer using this class as handler""" server_class = BaseHTTPServer.HTTPServer - httpd = server_class(("127.0.0.1", 0), cls) + httpd = server_class(("127.0.0.1", port), cls) (host, port) = httpd.socket.getsockname() def lauch(): @@ -178,3 +197,143 @@ class Http404Handler(HttpParamsHandler): def do_POST(self): """Called on a POST request on the BaseHTTPServer""" return self.do_GET() + + +class DummyCAS(BaseHTTPServer.BaseHTTPRequestHandler): + + def test_params(self): + if ( + self.server.ticket is not None and + self.params.get("service").encode("ascii") == self.server.service and + self.params.get("ticket").encode("ascii") == self.server.ticket + ): + self.server.ticket = None + print("good") + return True + else: + print("bad (%r, %r) != (%r, %r)" % ( + self.params.get("service").encode("ascii"), + self.params.get("ticket").encode("ascii"), + self.server.service, + self.server.ticket + )) + + return False + + def send_headers(self, code, content_type): + self.send_response(200) + self.send_header("Content-type", content_type) + self.end_headers() + + def do_GET(self): + url = urlparse(self.path) + self.params = dict(parse_qsl(url.query)) + if url.path == "/validate": + self.send_headers(200, "text/plain; charset=utf-8") + if self.test_params(): + self.wfile.write(b"yes\n" + self.server.username + b"\n") + self.server.ticket = None + else: + self.wfile.write(b"no\n") + elif url.path in { + '/serviceValidate', '/serviceValidate', + '/p3/serviceValidate', '/p3/proxyValidate' + }: + self.send_headers(200, "text/xml; charset=utf-8") + if self.test_params(): + t = loader.get_template('cas_server/serviceValidate.xml') + c = Context({ + 'username': self.server.username, + 'attributes': self.server.attributes + }) + self.wfile.write(return_bytes(t.render(c), "utf8")) + else: + t = loader.get_template('cas_server/serviceValidateError.xml') + c = Context({ + 'code': 'BAD_SERVICE_TICKET', + 'msg': 'Valids are (%r, %r)' % (self.server.service, self.server.ticket) + }) + self.wfile.write(return_bytes(t.render(c), "utf8")) + else: + self.return_404() + + def do_POST(self): + url = urlparse(self.path) + self.params = dict(parse_qsl(url.query)) + if url.path == "/samlValidate": + self.send_headers(200, "text/xml; charset=utf-8") + length = int(self.headers.get('content-length')) + root = etree.fromstring(self.rfile.read(length)) + auth_req = root.getchildren()[1].getchildren()[0] + ticket = auth_req.getchildren()[0].text.encode("ascii") + if ( + self.server.ticket is not None and + self.params.get("TARGET").encode("ascii") == self.server.service and + ticket == self.server.ticket + ): + self.server.ticket = None + t = loader.get_template('cas_server/samlValidate.xml') + c = Context({ + 'IssueInstant': timezone.now().isoformat(), + 'expireInstant': (timezone.now() + timedelta(seconds=60)).isoformat(), + 'Recipient': self.server.service, + 'ResponseID': utils.gen_saml_id(), + 'username': self.server.username, + 'attributes': self.server.attributes, + }) + self.wfile.write(return_bytes(t.render(c), "utf8")) + else: + t = loader.get_template('cas_server/samlValidateError.xml') + c = Context({ + 'IssueInstant': timezone.now().isoformat(), + 'ResponseID': utils.gen_saml_id(), + 'code': 'BAD_SERVICE_TICKET', + 'msg': 'Valids are (%r, %r)' % (self.server.service, self.server.ticket) + }) + self.wfile.write(return_bytes(t.render(c), "utf8")) + else: + self.return_404() + + def return_404(self): + self.send_response(404) + self.send_header(b"Content-type", "text/plain") + self.end_headers() + self.wfile.write("not found") + + def log_message(self, *args): + """silent any log message""" + return + + @classmethod + def run(cls, service, ticket, username, attributes, port=0): + """Run a BaseHTTPServer using this class as handler""" + server_class = BaseHTTPServer.HTTPServer + httpd = server_class(("127.0.0.1", port), cls) + httpd.service = service + httpd.ticket = ticket + httpd.username = username + httpd.attributes = attributes + (host, port) = httpd.socket.getsockname() + + def lauch(): + """routine to lauch in a background thread""" + httpd.handle_request() + httpd.server_close() + + httpd_thread = Thread(target=lauch) + httpd_thread.daemon = True + httpd_thread.start() + return (httpd, host, port) + + +def logout_request(ticket): + return u""" + +%(ticket)s +""" % \ + { + 'id': utils.gen_saml_id(), + 'datetime': timezone.now().isoformat(), + 'ticket': ticket + } diff --git a/cas_server/views.py b/cas_server/views.py index 9543c6f..05ce47d 100644 --- a/cas_server/views.py +++ b/cas_server/views.py @@ -123,15 +123,18 @@ class LogoutView(View, LogoutMixin): self.init_get(request) # if CAS federation mode is enable, bakup the provider before flushing the sessions if settings.CAS_FEDERATE: - component = self.request.session.get("username").split('@') - provider = component[-1] - auth = CASFederateValidateUser(provider, service_url="") + if "username" in self.request.session: + component = self.request.session["username"].split('@') + provider = component[-1] + auth = CASFederateValidateUser(provider, service_url="") + else: + auth = None session_nb = self.logout(self.request.GET.get("all")) # if CAS federation mode is enable, redirect to user CAS logout page if settings.CAS_FEDERATE: - params = utils.copy_params(request.GET) - url = utils.update_url(auth.get_logout_url(), params) - if url: + if auth is not None: + params = utils.copy_params(request.GET) + url = utils.update_url(auth.get_logout_url(), params) return HttpResponseRedirect(url) # if service is set, redirect to service after logout if self.service: @@ -195,7 +198,7 @@ class FederateAuth(View): @staticmethod def get_cas_client(request, provider): - if provider in settings.CAS_FEDERATE_PROVIDERS: + if provider in settings.CAS_FEDERATE_PROVIDERS: # pragma: no branch (should always be true) service_url = utils.get_current_url(request, {"ticket", "provider"}) return CASFederateValidateUser(provider, service_url) @@ -207,14 +210,14 @@ class FederateAuth(View): auth = self.get_cas_client(request, provider) try: auth.clean_sessions(request.POST['logoutRequest']) - except KeyError: + except (KeyError, AttributeError): pass return HttpResponse("ok") # else, a User is trying to log in using an identity provider else: # Manually checking for csrf to protect the code below reason = CsrfViewMiddleware().process_view(request, None, (), {}) - if reason is not None: + if reason is not None: # pragma: no cover (csrf checks are disabled during tests) return reason # Failed the test, stop here. form = forms.FederateSelect(request.POST) if form.is_valid(): @@ -252,7 +255,7 @@ class FederateAuth(View): ticket = request.GET['ticket'] if auth.verify_ticket(ticket): params = utils.copy_params(request.GET, ignore={"ticket"}) - username = "%s@%s" % (auth.username, auth.provider) + username = u"%s@%s" % (auth.username, auth.provider) request.session["federate_username"] = username request.session["federate_ticket"] = ticket auth.register_slo(username, request.session.session_key, ticket) @@ -281,9 +284,9 @@ class LoginView(View, LogoutMixin): renewed = False warned = False - if settings.CAS_FEDERATE: - username = None - ticket = None + # used if CAS_FEDERATE is True + username = None + ticket = None INVALID_LOGIN_TICKET = 1 USER_LOGIN_OK = 2 @@ -354,7 +357,7 @@ class LoginView(View, LogoutMixin): elif ret == self.USER_LOGIN_FAILURE: # bad user login if settings.CAS_FEDERATE: self.ticket = None - self.usernalme = None + self.username = None self.init_form() self.logout() elif ret == self.USER_ALREADY_LOGGED: @@ -682,11 +685,14 @@ class Auth(View): secret = request.POST.get('secret') if not settings.CAS_AUTH_SHARED_SECRET: - return HttpResponse("no\nplease set CAS_AUTH_SHARED_SECRET", content_type="text/plain") + return HttpResponse( + "no\nplease set CAS_AUTH_SHARED_SECRET", + content_type="text/plain; charset=utf-8" + ) if secret != settings.CAS_AUTH_SHARED_SECRET: - return HttpResponse("no\n", content_type="text/plain") + return HttpResponse(u"no\n", content_type="text/plain; charset=utf-8") if not username or not password or not service: - return HttpResponse("no\n", content_type="text/plain") + return HttpResponse(u"no\n", content_type="text/plain; charset=utf-8") form = forms.UserCredential( request.POST, initial={ @@ -714,11 +720,11 @@ class Auth(View): service_pattern.check_user(user) if not request.session.get("authenticated"): user.delete() - return HttpResponse("yes\n", content_type="text/plain") + return HttpResponse(u"yes\n", content_type="text/plain; charset=utf-8") except (ServicePattern.DoesNotExist, models.ServicePatternException): - return HttpResponse("no\n", content_type="text/plain") + return HttpResponse(u"no\n", content_type="text/plain; charset=utf-8") else: - return HttpResponse("no\n", content_type="text/plain") + return HttpResponse(u"no\n", content_type="text/plain; charset=utf-8") class Validate(View): @@ -758,7 +764,10 @@ class Validate(View): username = username[0] else: username = ticket.user.username - return HttpResponse("yes\n%s\n" % username, content_type="text/plain") + return HttpResponse( + u"yes\n%s\n" % username, + content_type="text/plain; charset=utf-8" + ) except ServiceTicket.DoesNotExist: logger.warning( ( @@ -769,10 +778,10 @@ class Validate(View): service ) ) - return HttpResponse("no\n", content_type="text/plain") + return HttpResponse(u"no\n", content_type="text/plain; charset=utf-8") else: logger.warning("Validate: service or ticket missing") - return HttpResponse("no\n", content_type="text/plain") + return HttpResponse(u"no\n", content_type="text/plain; charset=utf-8") class ValidateError(Exception): @@ -815,8 +824,8 @@ class ValidateService(View, AttributesMixin): if not self.service or not self.ticket: logger.warning("ValidateService: missing ticket or service") return ValidateError( - 'INVALID_REQUEST', - "you must specify a service and a ticket" + u'INVALID_REQUEST', + u"you must specify a service and a ticket" ).render(request) else: try: @@ -886,14 +895,14 @@ class ValidateService(View, AttributesMixin): for prox in ticket.proxies.all(): proxies.append(prox.url) else: - raise ValidateError('INVALID_TICKET', self.ticket) + raise ValidateError(u'INVALID_TICKET', self.ticket) ticket.validate = True ticket.save() if ticket.service != self.service: - raise ValidateError('INVALID_SERVICE', self.service) + raise ValidateError(u'INVALID_SERVICE', self.service) return ticket, proxies except (ServiceTicket.DoesNotExist, ProxyTicket.DoesNotExist): - raise ValidateError('INVALID_TICKET', 'ticket not found') + raise ValidateError(u'INVALID_TICKET', 'ticket not found') def process_pgturl(self, params): """Handle PGT request""" @@ -939,18 +948,18 @@ class ValidateService(View, AttributesMixin): except requests.exceptions.RequestException as error: error = utils.unpack_nested_exception(error) raise ValidateError( - 'INVALID_PROXY_CALLBACK', - "%s: %s" % (type(error), str(error)) + u'INVALID_PROXY_CALLBACK', + u"%s: %s" % (type(error), str(error)) ) else: raise ValidateError( - 'INVALID_PROXY_CALLBACK', - "callback url not allowed by configuration" + u'INVALID_PROXY_CALLBACK', + u"callback url not allowed by configuration" ) except ServicePattern.DoesNotExist: raise ValidateError( - 'INVALID_PROXY_CALLBACK', - 'callback url not allowed by configuration' + u'INVALID_PROXY_CALLBACK', + u'callback url not allowed by configuration' ) @@ -971,8 +980,8 @@ class Proxy(View): return self.process_proxy() else: raise ValidateError( - 'INVALID_REQUEST', - "you must specify and pgt and targetService" + u'INVALID_REQUEST', + u"you must specify and pgt and targetService" ) except ValidateError as error: logger.warning("Proxy: validation error: %s %s" % (error.code, error.msg)) @@ -985,8 +994,8 @@ class Proxy(View): pattern = ServicePattern.validate(self.target_service) if not pattern.proxy: raise ValidateError( - 'UNAUTHORIZED_SERVICE', - 'the service %s do not allow proxy ticket' % self.target_service + u'UNAUTHORIZED_SERVICE', + u'the service %s do not allow proxy ticket' % self.target_service ) # is the proxy granting ticket valid ticket = ProxyGrantingTicket.objects.get( @@ -1015,13 +1024,13 @@ class Proxy(View): content_type="text/xml; charset=utf-8" ) except ProxyGrantingTicket.DoesNotExist: - raise ValidateError('INVALID_TICKET', 'PGT %s not found' % self.pgt) + raise ValidateError(u'INVALID_TICKET', u'PGT %s not found' % self.pgt) except ServicePattern.DoesNotExist: - raise ValidateError('UNAUTHORIZED_SERVICE', self.target_service) + raise ValidateError(u'UNAUTHORIZED_SERVICE', self.target_service) except (models.BadUsername, models.BadFilter, models.UserFieldNotDefined): raise ValidateError( - 'UNAUTHORIZED_USER', - 'User %s not allowed on %s' % (ticket.user.username, self.target_service) + u'UNAUTHORIZED_USER', + u'User %s not allowed on %s' % (ticket.user.username, self.target_service) ) @@ -1129,18 +1138,18 @@ class SamlValidate(View, AttributesMixin): ) else: raise SamlValidateError( - 'AuthnFailed', - 'ticket %s should begin with PT- or ST-' % ticket + u'AuthnFailed', + u'ticket %s should begin with PT- or ST-' % ticket ) ticket.validate = True ticket.save() if ticket.service != self.target: raise SamlValidateError( - 'AuthnFailed', - 'TARGET %s do not match ticket service' % self.target + u'AuthnFailed', + u'TARGET %s do not match ticket service' % self.target ) return ticket except (IndexError, KeyError): - raise SamlValidateError('VersionMismatch') + raise SamlValidateError(u'VersionMismatch') except (ServiceTicket.DoesNotExist, ProxyTicket.DoesNotExist): - raise SamlValidateError('AuthnFailed', 'ticket %s not found' % ticket) + raise SamlValidateError(u'AuthnFailed', u'ticket %s not found' % ticket) From 7cc3ba689f54d07e84416ad89635dcca54b3c75c Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sun, 3 Jul 2016 14:19:39 +0200 Subject: [PATCH 26/34] Correct some code style errors and remove some forgotten debug lines --- cas_server/tests/test_federate.py | 33 ++++++++++++++++------ cas_server/tests/test_models.py | 47 +++++++++++++++---------------- cas_server/tests/utils.py | 40 ++++++++++---------------- cas_server/views.py | 5 ++-- 4 files changed, 65 insertions(+), 60 deletions(-) diff --git a/cas_server/tests/test_federate.py b/cas_server/tests/test_federate.py index b4e76b2..2fe4728 100644 --- a/cas_server/tests/test_federate.py +++ b/cas_server/tests/test_federate.py @@ -34,10 +34,6 @@ PROVIDERS_LIST = list(PROVIDERS.keys()) PROVIDERS_LIST.sort() -def getaddrinfo_mock(name, port, *args, **kwargs): - return [(2, 1, 6, '', ('127.0.0.1', 80))] - - @override_settings( CAS_FEDERATE=True, CAS_FEDERATE_PROVIDERS=PROVIDERS, @@ -187,7 +183,6 @@ class FederateAuthLoginLogoutTestCase(TestCase, BaseServicePattern, CanLogin): try to fetch a new ticket if the provided ticket validation fail (network error or bad ticket) """ - return good_provider = "example.com" bad_provider = "exemple.fr" client = Client() @@ -285,7 +280,7 @@ class FederateAuthLoginLogoutTestCase(TestCase, BaseServicePattern, CanLogin): test the logout function: the user should be log out and redirected to his CAS logout page """ - # get tickets and connected clients + # get tickets and connected clients, then follow normal logout tickets = self.test_login_post_provider() for (provider, _, client) in tickets: response = client.get("/logout") @@ -297,6 +292,28 @@ class FederateAuthLoginLogoutTestCase(TestCase, BaseServicePattern, CanLogin): response = client.get("/login") self.assert_login_failed(client, response) + # test if the user is already logged out + response = client.get("/logout") + # no redirection + self.assertEqual(response.status_code, 200) + self.assertTrue( + ( + b"You were already logged out from the Central Authentication Service." + ) in response.content + ) + + tickets = self.test_login_post_provider() + if django.VERSION >= (1, 8): + # assume the username session variable has been tempered (should not happend) + for (provider, _, client) in tickets: + session = client.session + session["username"] = settings.CAS_TEST_USER + session.save() + response = client.get("/logout") + self.assertEqual(response.status_code, 200) + response = client.get("/login") + self.assert_login_failed(client, response) + def test_remember_provider(self): """ If the user check remember, next login should not offer the chose of the backend CAS @@ -323,7 +340,7 @@ class FederateAuthLoginLogoutTestCase(TestCase, BaseServicePattern, CanLogin): session = client.session session["federate_username"] = '%s@%s' % (settings.CAS_TEST_USER, provider) session["federate_ticket"] = utils.gen_st() - try: + if django.VERSION >= (1, 8): session.save() response = client.get("/login") # we should get a page with a from with all widget hidden that auto POST to /login using @@ -340,5 +357,3 @@ class FederateAuthLoginLogoutTestCase(TestCase, BaseServicePattern, CanLogin): utils.get_tuple(value, 2, key) ) in response.content.decode("utf-8")) self.assertEqual(response.context['post_url'], '/federate') - except AttributeError: - pass diff --git a/cas_server/tests/test_models.py b/cas_server/tests/test_models.py index cdaece8..93825d8 100644 --- a/cas_server/tests/test_models.py +++ b/cas_server/tests/test_models.py @@ -12,6 +12,7 @@ """Tests module for models""" from cas_server.default_settings import settings +import django from django.test import TestCase, Client from django.test.utils import override_settings from django.utils import timezone @@ -60,31 +61,29 @@ class FederateSLOTestCase(TestCase, UserModels): tests for clean_deleted_sessions that should delete object for which matching session do not exists anymore """ - client1 = Client() - client2 = Client() - client1.get("/login") - client2.get("/login") - session = client2.session - session['authenticated'] = True - try: + if django.VERSION >= (1, 8): + client1 = Client() + client2 = Client() + client1.get("/login") + client2.get("/login") + session = client2.session + session['authenticated'] = True session.save() - except AttributeError: - pass - models.FederateSLO.objects.create( - username="test1@example.com", - session_key=client1.session.session_key, - ticket=utils.gen_st() - ) - models.FederateSLO.objects.create( - username="test2@example.com", - session_key=client2.session.session_key, - ticket=utils.gen_st() - ) - self.assertEqual(len(models.FederateSLO.objects.all()), 2) - models.FederateSLO.clean_deleted_sessions() - self.assertEqual(len(models.FederateSLO.objects.all()), 1) - with self.assertRaises(models.FederateSLO.DoesNotExist): - models.FederateSLO.objects.get(username="test1@example.com") + models.FederateSLO.objects.create( + username="test1@example.com", + session_key=client1.session.session_key, + ticket=utils.gen_st() + ) + models.FederateSLO.objects.create( + username="test2@example.com", + session_key=client2.session.session_key, + ticket=utils.gen_st() + ) + self.assertEqual(len(models.FederateSLO.objects.all()), 2) + models.FederateSLO.clean_deleted_sessions() + self.assertEqual(len(models.FederateSLO.objects.all()), 1) + with self.assertRaises(models.FederateSLO.DoesNotExist): + models.FederateSLO.objects.get(username="test1@example.com") @override_settings(CAS_AUTH_CLASS='cas_server.auth.TestAuthUser') diff --git a/cas_server/tests/utils.py b/cas_server/tests/utils.py index b8419c6..cd57922 100644 --- a/cas_server/tests/utils.py +++ b/cas_server/tests/utils.py @@ -208,20 +208,12 @@ class DummyCAS(BaseHTTPServer.BaseHTTPRequestHandler): self.params.get("ticket").encode("ascii") == self.server.ticket ): self.server.ticket = None - print("good") return True else: - print("bad (%r, %r) != (%r, %r)" % ( - self.params.get("service").encode("ascii"), - self.params.get("ticket").encode("ascii"), - self.server.service, - self.server.ticket - )) - return False def send_headers(self, code, content_type): - self.send_response(200) + self.send_response(code) self.send_header("Content-type", content_type) self.end_headers() @@ -241,19 +233,19 @@ class DummyCAS(BaseHTTPServer.BaseHTTPRequestHandler): }: self.send_headers(200, "text/xml; charset=utf-8") if self.test_params(): - t = loader.get_template('cas_server/serviceValidate.xml') - c = Context({ + template = loader.get_template('cas_server/serviceValidate.xml') + context = Context({ 'username': self.server.username, 'attributes': self.server.attributes }) - self.wfile.write(return_bytes(t.render(c), "utf8")) + self.wfile.write(return_bytes(template.render(context), "utf8")) else: - t = loader.get_template('cas_server/serviceValidateError.xml') - c = Context({ + template = loader.get_template('cas_server/serviceValidateError.xml') + context = Context({ 'code': 'BAD_SERVICE_TICKET', 'msg': 'Valids are (%r, %r)' % (self.server.service, self.server.ticket) }) - self.wfile.write(return_bytes(t.render(c), "utf8")) + self.wfile.write(return_bytes(template.render(context), "utf8")) else: self.return_404() @@ -272,8 +264,8 @@ class DummyCAS(BaseHTTPServer.BaseHTTPRequestHandler): ticket == self.server.ticket ): self.server.ticket = None - t = loader.get_template('cas_server/samlValidate.xml') - c = Context({ + template = loader.get_template('cas_server/samlValidate.xml') + context = Context({ 'IssueInstant': timezone.now().isoformat(), 'expireInstant': (timezone.now() + timedelta(seconds=60)).isoformat(), 'Recipient': self.server.service, @@ -281,24 +273,22 @@ class DummyCAS(BaseHTTPServer.BaseHTTPRequestHandler): 'username': self.server.username, 'attributes': self.server.attributes, }) - self.wfile.write(return_bytes(t.render(c), "utf8")) + self.wfile.write(return_bytes(template.render(context), "utf8")) else: - t = loader.get_template('cas_server/samlValidateError.xml') - c = Context({ + template = loader.get_template('cas_server/samlValidateError.xml') + context = Context({ 'IssueInstant': timezone.now().isoformat(), 'ResponseID': utils.gen_saml_id(), 'code': 'BAD_SERVICE_TICKET', 'msg': 'Valids are (%r, %r)' % (self.server.service, self.server.ticket) }) - self.wfile.write(return_bytes(t.render(c), "utf8")) + self.wfile.write(return_bytes(template.render(context), "utf8")) else: self.return_404() def return_404(self): - self.send_response(404) - self.send_header(b"Content-type", "text/plain") - self.end_headers() - self.wfile.write("not found") + self.send_headers(404, "text/plain; charset=utf-8") + self.wfile.write("not found") def log_message(self, *args): """silent any log message""" diff --git a/cas_server/views.py b/cas_server/views.py index 05ce47d..7ed15cd 100644 --- a/cas_server/views.py +++ b/cas_server/views.py @@ -134,8 +134,9 @@ class LogoutView(View, LogoutMixin): if settings.CAS_FEDERATE: if auth is not None: params = utils.copy_params(request.GET) - url = utils.update_url(auth.get_logout_url(), params) - return HttpResponseRedirect(url) + url = auth.get_logout_url() + if url: + return HttpResponseRedirect(utils.update_url(url, params)) # if service is set, redirect to service after logout if self.service: list(messages.get_messages(request)) # clean messages before leaving the django app From 8e5b75e09016b58809a1fd5355bc7660ee86d73a Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sun, 3 Jul 2016 17:54:11 +0200 Subject: [PATCH 27/34] Add some docstrings --- cas_server/auth.py | 1 + cas_server/federate.py | 10 ++++++++-- cas_server/forms.py | 4 ++++ cas_server/models.py | 5 +++++ cas_server/tests/utils.py | 13 ++++++++++++- cas_server/utils.py | 7 +++++++ cas_server/views.py | 6 +++++- 7 files changed, 42 insertions(+), 4 deletions(-) diff --git a/cas_server/auth.py b/cas_server/auth.py index d666ec5..dd764b6 100644 --- a/cas_server/auth.py +++ b/cas_server/auth.py @@ -144,6 +144,7 @@ class DjangoAuthUser(AuthUser): # pragma: no cover class CASFederateAuth(AuthUser): + """Authentication class used then CAS_FEDERATE is True""" user = None def __init__(self, username): diff --git a/cas_server/federate.py b/cas_server/federate.py index 2f6489a..d2ddcb1 100644 --- a/cas_server/federate.py +++ b/cas_server/federate.py @@ -1,4 +1,4 @@ -# ⁻*- coding: utf-8 -*- +# -*- 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 @@ -9,6 +9,7 @@ # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # (c) 2015 Valentin Samir +"""federated mode helper classes""" from .default_settings import settings from .cas import CASClient @@ -21,6 +22,7 @@ SessionStore = import_module(settings.SESSION_ENGINE).SessionStore class CASFederateValidateUser(object): + """Class CAS client used to authenticate the user again a CAS provider""" username = None attributs = {} client = None @@ -38,13 +40,15 @@ class CASFederateValidateUser(object): ) def get_login_url(self): + """return the CAS provider login url""" return self.client.get_login_url() if self.client is not None else False def get_logout_url(self, redirect_url=None): + """return the CAS provider logout url""" return self.client.get_logout_url(redirect_url) if self.client is not None else False def verify_ticket(self, ticket): - """test `password` agains the user""" + """test `ticket` agains the CAS provider, if valid, create the local federated user""" if self.client is None: # pragma: no cover (should not happen) return False try: @@ -79,6 +83,7 @@ class CASFederateValidateUser(object): @staticmethod def register_slo(username, session_key, ticket): + """association a ticket with a (username, session) for processing later SLO request""" FederateSLO.objects.create( username=username, session_key=session_key, @@ -86,6 +91,7 @@ class CASFederateValidateUser(object): ) def clean_sessions(self, logout_request): + """process a SLO request""" try: slos = self.client.get_saml_slos(logout_request) or [] except NameError: # pragma: no cover (should not happen) diff --git a/cas_server/forms.py b/cas_server/forms.py index dc0e866..233938e 100644 --- a/cas_server/forms.py +++ b/cas_server/forms.py @@ -29,6 +29,10 @@ class WarnForm(forms.Form): class FederateSelect(forms.Form): + """ + Form used on the login page when CAS_FEDERATE is True + allowing the user to choose a identity provider. + """ provider = forms.ChoiceField( label=_('Identity provider'), # with use a lambda abstraction to delay the access to settings.CAS_FEDERATE_PROVIDERS diff --git a/cas_server/models.py b/cas_server/models.py index 3d1f17f..581017d 100644 --- a/cas_server/models.py +++ b/cas_server/models.py @@ -35,6 +35,7 @@ logger = logging.getLogger(__name__) class FederatedUser(models.Model): + """A federated user as returner by a CAS provider (username and attributes)""" class Meta: unique_together = ("username", "provider") username = models.CharField(max_length=124) @@ -48,6 +49,7 @@ class FederatedUser(models.Model): @classmethod def clean_old_entries(cls): + """remove old unused federated users""" federated_users = cls.objects.filter( last_update__lt=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_TIMEOUT)) ) @@ -58,6 +60,7 @@ class FederatedUser(models.Model): class FederateSLO(models.Model): + """An association between a CAS provider ticket and a (username, session) for processing SLO""" class Meta: unique_together = ("username", "session_key") username = models.CharField(max_length=30) @@ -66,6 +69,7 @@ class FederateSLO(models.Model): @classmethod def clean_deleted_sessions(cls): + """remove old object for which the session do not exists anymore""" for federate_slo in cls.objects.all(): if not SessionStore(session_key=federate_slo.session_key).get('authenticated'): federate_slo.delete() @@ -82,6 +86,7 @@ class User(models.Model): date = models.DateTimeField(auto_now=True) def delete(self, *args, **kwargs): + """remove the User""" if settings.CAS_FEDERATE: FederateSLO.objects.filter( username=self.username, diff --git a/cas_server/tests/utils.py b/cas_server/tests/utils.py index cd57922..6017c2f 100644 --- a/cas_server/tests/utils.py +++ b/cas_server/tests/utils.py @@ -29,6 +29,7 @@ from cas_server import utils def return_unicode(string, charset): + """make `string` a unicode if `string` is a unicode or bytes encoded with `charset`""" if not isinstance(string, six.text_type): return string.decode(charset) else: @@ -36,6 +37,10 @@ def return_unicode(string, charset): def return_bytes(string, charset): + """ + make `string` a bytes encoded with `charset` if `string` is a unicode + or bytes encoded with `charset` + """ if isinstance(string, six.text_type): return string.encode(charset) else: @@ -200,8 +205,9 @@ class Http404Handler(HttpParamsHandler): class DummyCAS(BaseHTTPServer.BaseHTTPRequestHandler): - + """A dummy CAS that validate for only one (service, ticket) used in federated mode tests""" def test_params(self): + """check that internal and provided (service, ticket) matches""" if ( self.server.ticket is not None and self.params.get("service").encode("ascii") == self.server.service and @@ -213,11 +219,13 @@ class DummyCAS(BaseHTTPServer.BaseHTTPRequestHandler): return False def send_headers(self, code, content_type): + """send http headers""" self.send_response(code) self.send_header("Content-type", content_type) self.end_headers() def do_GET(self): + """Called on a GET request on the BaseHTTPServer""" url = urlparse(self.path) self.params = dict(parse_qsl(url.query)) if url.path == "/validate": @@ -250,6 +258,7 @@ class DummyCAS(BaseHTTPServer.BaseHTTPRequestHandler): self.return_404() def do_POST(self): + """Called on a POST request on the BaseHTTPServer""" url = urlparse(self.path) self.params = dict(parse_qsl(url.query)) if url.path == "/samlValidate": @@ -287,6 +296,7 @@ class DummyCAS(BaseHTTPServer.BaseHTTPRequestHandler): self.return_404() def return_404(self): + """return a 404 error""" self.send_headers(404, "text/plain; charset=utf-8") self.wfile.write("not found") @@ -317,6 +327,7 @@ class DummyCAS(BaseHTTPServer.BaseHTTPRequestHandler): def logout_request(ticket): + """build a SLO request XML, ready to be send""" return u""" diff --git a/cas_server/utils.py b/cas_server/utils.py index 1da1c1e..72f1369 100644 --- a/cas_server/utils.py +++ b/cas_server/utils.py @@ -76,6 +76,7 @@ def reverse_params(url_name, params=None, **kwargs): def copy_params(get_or_post_params, ignore=None): + """copy from a dictionnary like `get_or_post_params` ignoring keys in the set `ignore`""" if ignore is None: ignore = set() params = {} @@ -86,6 +87,7 @@ def copy_params(get_or_post_params, ignore=None): def set_cookie(response, key, value, max_age): + """Set the cookie `key` on `response` with value `value` valid for `max_age` secondes""" expires = datetime.strftime( datetime.utcnow() + timedelta(seconds=max_age), "%a, %d-%b-%Y %H:%M:%S GMT" @@ -101,6 +103,7 @@ def set_cookie(response, key, value, max_age): def get_current_url(request, ignore_params=None): + """Giving a django request, return the current http url, possibly ignoring some GET params""" if ignore_params is None: ignore_params = set() protocol = 'https' if request.is_secure() else "http" @@ -194,6 +197,10 @@ def gen_saml_id(): def get_tuple(nuplet, index, default=None): + """ + return the value in index `index` of the tuple `nuplet` if it exists, + else return `default` + """ if nuplet is None: return default try: diff --git a/cas_server/views.py b/cas_server/views.py index 7ed15cd..1e2edf0 100644 --- a/cas_server/views.py +++ b/cas_server/views.py @@ -192,18 +192,21 @@ class LogoutView(View, LogoutMixin): class FederateAuth(View): - + """view to authenticated user agains a backend CAS then CAS_FEDERATE is True""" @method_decorator(csrf_exempt) def dispatch(self, request, *args, **kwargs): + """dispatch different http request to the methods of the same name""" return super(FederateAuth, self).dispatch(request, *args, **kwargs) @staticmethod def get_cas_client(request, provider): + """return a CAS client object matching provider""" if provider in settings.CAS_FEDERATE_PROVIDERS: # pragma: no branch (should always be true) service_url = utils.get_current_url(request, {"ticket", "provider"}) return CASFederateValidateUser(provider, service_url) def post(self, request, provider=None): + """method called on POST request""" if not settings.CAS_FEDERATE: return redirect("cas_server:login") # POST with a provider, this is probably an SLO request @@ -245,6 +248,7 @@ class FederateAuth(View): return redirect("cas_server:login") def get(self, request, provider=None): + """method called on GET request""" if not settings.CAS_FEDERATE: return redirect("cas_server:login") if provider not in settings.CAS_FEDERATE_PROVIDERS: From 9909699012e82e51a97c799cb87b2e8720e51399 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sun, 3 Jul 2016 18:11:48 +0200 Subject: [PATCH 28/34] Update sime legal headers --- cas_server/auth.py | 2 +- cas_server/cas.py | 1 + cas_server/default_settings.py | 4 ++-- cas_server/federate.py | 2 +- cas_server/management/commands/cas_clean_federate.py | 11 +++++++++++ cas_server/management/commands/cas_clean_sessions.py | 11 +++++++++++ cas_server/management/commands/cas_clean_tickets.py | 11 +++++++++++ cas_server/models.py | 2 +- cas_server/tests/mixin.py | 2 +- cas_server/tests/test_models.py | 2 +- cas_server/tests/test_utils.py | 2 +- cas_server/tests/utils.py | 2 +- cas_server/urls.py | 2 +- cas_server/views.py | 2 +- 14 files changed, 45 insertions(+), 11 deletions(-) diff --git a/cas_server/auth.py b/cas_server/auth.py index dd764b6..160adc2 100644 --- a/cas_server/auth.py +++ b/cas_server/auth.py @@ -1,4 +1,4 @@ -# ⁻*- coding: utf-8 -*- +# -*- 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 diff --git a/cas_server/cas.py b/cas_server/cas.py index fdc22fb..9eec396 100644 --- a/cas_server/cas.py +++ b/cas_server/cas.py @@ -20,6 +20,7 @@ # This file is originated from https://github.com/python-cas/python-cas # at commit ec1f2d4779625229398547b9234d0e9e874a2c9a +# some modifications have been made to be unicode coherent between python2 and python2 import six from six.moves.urllib import parse as urllib_parse diff --git a/cas_server/default_settings.py b/cas_server/default_settings.py index be3f064..6b418fd 100644 --- a/cas_server/default_settings.py +++ b/cas_server/default_settings.py @@ -1,4 +1,4 @@ -# ⁻*- coding: utf-8 -*- +# -*- 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 @@ -8,7 +8,7 @@ # along with this program; if not, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # -# (c) 2015 Valentin Samir +# (c) 2015-2016 Valentin Samir """Default values for the app's settings""" from django.conf import settings from django.contrib.staticfiles.templatetags.staticfiles import static diff --git a/cas_server/federate.py b/cas_server/federate.py index d2ddcb1..997e56f 100644 --- a/cas_server/federate.py +++ b/cas_server/federate.py @@ -8,7 +8,7 @@ # along with this program; if not, write to the Free Software Foundation, Inc., 51 # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # -# (c) 2015 Valentin Samir +# (c) 2016 Valentin Samir """federated mode helper classes""" from .default_settings import settings diff --git a/cas_server/management/commands/cas_clean_federate.py b/cas_server/management/commands/cas_clean_federate.py index 8d91935..795230a 100644 --- a/cas_server/management/commands/cas_clean_federate.py +++ b/cas_server/management/commands/cas_clean_federate.py @@ -1,3 +1,14 @@ +# -*- 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 from django.core.management.base import BaseCommand from django.utils.translation import ugettext_lazy as _ diff --git a/cas_server/management/commands/cas_clean_sessions.py b/cas_server/management/commands/cas_clean_sessions.py index 3d32090..437bcb5 100644 --- a/cas_server/management/commands/cas_clean_sessions.py +++ b/cas_server/management/commands/cas_clean_sessions.py @@ -1,3 +1,14 @@ +# -*- 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 """Clean deleted sessions management command""" from django.core.management.base import BaseCommand from django.utils.translation import ugettext_lazy as _ diff --git a/cas_server/management/commands/cas_clean_tickets.py b/cas_server/management/commands/cas_clean_tickets.py index dfbd4ec..87d802e 100644 --- a/cas_server/management/commands/cas_clean_tickets.py +++ b/cas_server/management/commands/cas_clean_tickets.py @@ -1,3 +1,14 @@ +# -*- 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 """Clean old trickets management command""" from django.core.management.base import BaseCommand from django.utils.translation import ugettext_lazy as _ diff --git a/cas_server/models.py b/cas_server/models.py index 581017d..17c4d83 100644 --- a/cas_server/models.py +++ b/cas_server/models.py @@ -1,4 +1,4 @@ -# ⁻*- coding: utf-8 -*- +# -*- 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 diff --git a/cas_server/tests/mixin.py b/cas_server/tests/mixin.py index 4612fd2..09ddadc 100644 --- a/cas_server/tests/mixin.py +++ b/cas_server/tests/mixin.py @@ -1,4 +1,4 @@ -# ⁻*- coding: utf-8 -*- +# -*- 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 diff --git a/cas_server/tests/test_models.py b/cas_server/tests/test_models.py index 93825d8..e027429 100644 --- a/cas_server/tests/test_models.py +++ b/cas_server/tests/test_models.py @@ -1,4 +1,4 @@ -# ⁻*- coding: utf-8 -*- +# -*- 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 diff --git a/cas_server/tests/test_utils.py b/cas_server/tests/test_utils.py index 411848a..f2fcbfc 100644 --- a/cas_server/tests/test_utils.py +++ b/cas_server/tests/test_utils.py @@ -1,4 +1,4 @@ -# ⁻*- coding: utf-8 -*- +# -*- 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 diff --git a/cas_server/tests/utils.py b/cas_server/tests/utils.py index 6017c2f..67e7c7b 100644 --- a/cas_server/tests/utils.py +++ b/cas_server/tests/utils.py @@ -1,4 +1,4 @@ -# ⁻*- coding: utf-8 -*- +# -*- 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 diff --git a/cas_server/urls.py b/cas_server/urls.py index 48ac4b7..5881557 100644 --- a/cas_server/urls.py +++ b/cas_server/urls.py @@ -1,4 +1,4 @@ -# ⁻*- coding: utf-8 -*- +# -*- 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 diff --git a/cas_server/views.py b/cas_server/views.py index 1e2edf0..55ecec4 100644 --- a/cas_server/views.py +++ b/cas_server/views.py @@ -1,4 +1,4 @@ -# ⁻*- coding: utf-8 -*- +# -*- 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 From 646cdba6aabf8a7894c19e68b22e91fcd3e49972 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sun, 3 Jul 2016 18:12:02 +0200 Subject: [PATCH 29/34] Do not display messages if auto_submit is True and javascript is enabled --- cas_server/templates/cas_server/base.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cas_server/templates/cas_server/base.html b/cas_server/templates/cas_server/base.html index 8c696cf..bebf439 100644 --- a/cas_server/templates/cas_server/base.html +++ b/cas_server/templates/cas_server/base.html @@ -24,7 +24,9 @@
+{% if auto_submit %}{% endif %} {% block content %} {% endblock %}
From d6c6bac5ebfd0110e5ae39f3328fac9ef32ef9a1 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sun, 3 Jul 2016 18:44:00 +0200 Subject: [PATCH 30/34] Add federated mode to features list in README.rst --- README.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/README.rst b/README.rst index 3c10fce..4819129 100644 --- a/README.rst +++ b/README.rst @@ -40,6 +40,7 @@ Features * Fine control on which user's attributes are passed to which service * Possibility to rename/rewrite attributes per service * Possibility to require some attribute values per service +* Federated mode between multiple CAS * Supports Django 1.7, 1.8 and 1.9 * Supports Python 2.7, 3.x From aa433d3c58e896b92937465ecf2e38f490b7ab71 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Mon, 4 Jul 2016 17:23:11 +0200 Subject: [PATCH 31/34] Use django admin application to add/modif identty providers when CAS_FEDERATE is True --- .coveragerc | 1 + .gitignore | 2 + README.rst | 39 ++-- cas_server/admin.py | 6 + cas_server/auth.py | 9 +- cas_server/default_settings.py | 25 --- cas_server/federate.py | 58 +++--- cas_server/forms.py | 23 +-- cas_server/locale/en/LC_MESSAGES/django.mo | Bin 6117 -> 6105 bytes cas_server/locale/en/LC_MESSAGES/django.po | 183 ++++++++++------- cas_server/locale/fr/LC_MESSAGES/django.mo | Bin 7285 -> 8347 bytes cas_server/locale/fr/LC_MESSAGES/django.po | 189 +++++++++++------- .../migrations/0007_auto_20160704_1510.py | 50 +++++ cas_server/models.py | 124 ++++++++++-- cas_server/tests/mixin.py | 63 +++--- cas_server/tests/test_federate.py | 93 ++++----- cas_server/tests/test_models.py | 17 +- cas_server/views.py | 106 +++++----- 18 files changed, 600 insertions(+), 388 deletions(-) create mode 100644 cas_server/migrations/0007_auto_20160704_1510.py diff --git a/.coveragerc b/.coveragerc index 9163f3e..771fe83 100644 --- a/.coveragerc +++ b/.coveragerc @@ -12,6 +12,7 @@ exclude_lines = pragma: no cover def __repr__ def __unicode__ + def __str__ raise AssertionError raise NotImplementedError if six.PY3: diff --git a/.gitignore b/.gitignore index 3b1bcb6..273399d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ coverage.xml test_venv .coverage htmlcov/ +tox_logs/ +.cache/ diff --git a/README.rst b/README.rst index 4819129..ebcaaa0 100644 --- a/README.rst +++ b/README.rst @@ -165,12 +165,6 @@ Federation settings * ``CAS_FEDERATE``: A boolean for activating the federated mode (see the federate section below). The default is ``False``. -* ``CAS_FEDERATE_PROVIDERS``: A dictionnary for the allowed identity providers (see the federate - section below). The default is ``{}``. -* ``CAS_FEDERATE_PROVIDERS_LIST``: A list in with the keys of ``CAS_FEDERATE_PROVIDERS`` are ordened - for beeing displayed on the login page. The default is the list of all the keys of - ``CAS_FEDERATE_PROVIDERS`` sorted in natural order (0 < 2 < 10 < 20 < a = A < … < z = Z and - lexicographical) * ``CAS_FEDERATE_REMEMBER_TIMEOUT``: Time after witch the cookie use for "remember my identity provider" expire. The default is ``604800``, one week. The cookie is called ``_remember_provider``. @@ -344,26 +338,29 @@ to the provider CAS to authenticate. This provider transmit to ``django-cas-serv username and attributes. The user is now logged in on ``django-cas-server`` and can use services using ``django-cas-server`` as CAS. -The list of allowed identity providers is defined using the ``CAS_FEDERATE_PROVIDERS`` parameter. -For instance: +The list of allowed identity providers is defined using the django admin application. +With the development server started, visit http://127.0.0.1:8000/admin/ to add identity providers. -.. code-block:: python +An identity provider comes with 5 fields: - CAS_FEDERATE_PROVIDERS = { - "example.com": ("https://cas.example.com", 3, "Example dot com"), - "exemple.fr": ("https://cas.exemple.fr", 3, "Exemple point fr"), - } +* `Position`: an integer used to tweak the order in which identity providers are displayed on + the login page. Identity providers are sorted using position first, then, on equal position, + using `verbose name` and then, on equal `verbose name`, using `suffix`. +* `Suffix`: the suffix that will be append to the username returned by the identity provider. + It must be unique. +* `Server url`: the url to the identity provider CAS. For instance, if you are using + `https://cas.example.org/login` to authenticate on the CAS, the `server url` is + `https://cas.example.org` +* `CAS protocol version`: the version of the CAS protocol to use to contact the identity provider. + The default is version 3. +* `Verbose name`: the name used on the login page to display the identity provider. -``CAS_FEDERATE_PROVIDERS`` is a dictionnary using provider names as key and a tuple -(cas address, cas version protocol, provider verbose name) as value. - In federation mode, ``django-cas-server`` build user's username as follow: -``provider_returned_username@provider_name``. -You can choose the provider returned username for ``django-cas-server`` and the provider name -in order to make sense. - -The "provider verbose name" is showed on the select menu of the login page. +``provider_returned_username@provider_suffix``. +Choose the provider returned username for ``django-cas-server`` and the provider suffix +in order to make sense, as this built username is likely to be displayed to end users in +applications. Then using federate mode, you should add one command to a daily crontab: ``cas_clean_federate``. diff --git a/cas_server/admin.py b/cas_server/admin.py index 472e1df..f2baf81 100644 --- a/cas_server/admin.py +++ b/cas_server/admin.py @@ -12,6 +12,7 @@ from django.contrib import admin from .models import ServiceTicket, ProxyTicket, ProxyGrantingTicket, User, ServicePattern from .models import Username, ReplaceAttributName, ReplaceAttributValue, FilterAttributValue +from .models import FederatedIendityProvider from .forms import TicketForm TICKETS_READONLY_FIELDS = ('validate', 'service', 'service_pattern', @@ -91,5 +92,10 @@ class ServicePatternAdmin(admin.ModelAdmin): 'single_log_out', 'proxy_callback', 'restrict_users') +class FederatedIendityProviderAdmin(admin.ModelAdmin): + fields = ('pos', 'suffix', 'server_url', 'cas_protocol_version', 'verbose_name') + + admin.site.register(User, UserAdmin) admin.site.register(ServicePattern, ServicePatternAdmin) +admin.site.register(FederatedIendityProvider, FederatedIendityProviderAdmin) diff --git a/cas_server/auth.py b/cas_server/auth.py index 160adc2..9f40ae4 100644 --- a/cas_server/auth.py +++ b/cas_server/auth.py @@ -148,16 +148,13 @@ class CASFederateAuth(AuthUser): user = None def __init__(self, username): - component = username.split('@') - username = '@'.join(component[:-1]) - provider = component[-1] try: - self.user = FederatedUser.objects.get(username=username, provider=provider) + self.user = FederatedUser.get_from_federated_username(username) super(CASFederateAuth, self).__init__( - "%s@%s" % (self.user.username, self.user.provider) + self.user.federated_username ) except FederatedUser.DoesNotExist: - super(CASFederateAuth, self).__init__("%s@%s" % (username, provider)) + super(CASFederateAuth, self).__init__(username) def test_password(self, ticket): """test `password` agains the user""" diff --git a/cas_server/default_settings.py b/cas_server/default_settings.py index 6b418fd..0b24f62 100644 --- a/cas_server/default_settings.py +++ b/cas_server/default_settings.py @@ -13,8 +13,6 @@ from django.conf import settings from django.contrib.staticfiles.templatetags.staticfiles import static -import re - def setting_default(name, default_value): """if the config `name` is not set, set it the `default_value`""" @@ -92,30 +90,7 @@ setting_default( setting_default('CAS_ENABLE_AJAX_AUTH', False) setting_default('CAS_FEDERATE', False) -# A dict of "provider suffix" -> (provider CAS server url, CAS version, verbose name) -setting_default('CAS_FEDERATE_PROVIDERS', {}) setting_default('CAS_FEDERATE_REMEMBER_TIMEOUT', 604800) # one week if settings.CAS_FEDERATE: settings.CAS_AUTH_CLASS = "cas_server.auth.CASFederateAuth" - -# create CAS_FEDERATE_PROVIDERS_LIST default value if not set: list of -# the keys of CAS_FEDERATE_PROVIDERS in natural order: 2 < 10 < 20 < a = A < … < z = Z -try: - getattr(settings, 'CAS_FEDERATE_PROVIDERS_LIST') -except AttributeError: - __CAS_FEDERATE_PROVIDERS_LIST = list(settings.CAS_FEDERATE_PROVIDERS.keys()) - - def __cas_federate_providers_list_sort(key): - if len(settings.CAS_FEDERATE_PROVIDERS[key]) > 2: - key = settings.CAS_FEDERATE_PROVIDERS[key][2].lower() - else: - key = key.lower() - return tuple( - int(num) if num else alpha - for num, alpha in __cas_federate_providers_list_sort.tokenize(key) - ) - __cas_federate_providers_list_sort.tokenize = re.compile(r'(\d+)|(\D+)').findall - __CAS_FEDERATE_PROVIDERS_LIST.sort(key=__cas_federate_providers_list_sort) - - setting_default('CAS_FEDERATE_PROVIDERS_LIST', __CAS_FEDERATE_PROVIDERS_LIST) diff --git a/cas_server/federate.py b/cas_server/federate.py index 997e56f..4534cda 100644 --- a/cas_server/federate.py +++ b/cas_server/federate.py @@ -11,6 +11,7 @@ # (c) 2016 Valentin Samir """federated mode helper classes""" from .default_settings import settings +from django.db import IntegrityError from .cas import CASClient from .models import FederatedUser, FederateSLO, User @@ -29,28 +30,23 @@ class CASFederateValidateUser(object): def __init__(self, provider, service_url): self.provider = provider - - if provider in settings.CAS_FEDERATE_PROVIDERS: # pragma: no branch (should always be True) - (server_url, version) = settings.CAS_FEDERATE_PROVIDERS[provider][:2] - self.client = CASClient( - service_url=service_url, - version=version, - server_url=server_url, - renew=False, - ) + self.client = CASClient( + service_url=service_url, + version=provider.cas_protocol_version, + server_url=provider.server_url, + renew=False, + ) def get_login_url(self): """return the CAS provider login url""" - return self.client.get_login_url() if self.client is not None else False + return self.client.get_login_url() def get_logout_url(self, redirect_url=None): """return the CAS provider logout url""" - return self.client.get_logout_url(redirect_url) if self.client is not None else False + return self.client.get_logout_url(redirect_url) def verify_ticket(self, ticket): """test `ticket` agains the CAS provider, if valid, create the local federated user""" - if self.client is None: # pragma: no cover (should not happen) - return False try: username, attributs = self.client.verify_ticket(ticket)[:2] except urllib.error.URLError: @@ -61,22 +57,13 @@ class CASFederateValidateUser(object): attributs["provider"] = self.provider self.username = username self.attributs = attributs - try: - user = FederatedUser.objects.get( - username=username, - provider=self.provider - ) - user.attributs = attributs - user.ticket = ticket - user.save() - except FederatedUser.DoesNotExist: - user = FederatedUser.objects.create( - username=username, - provider=self.provider, - attributs=attributs, - ticket=ticket - ) - user.save() + user = FederatedUser.objects.update_or_create( + username=username, + provider=self.provider, + defaults=dict(attributs=attributs, ticket=ticket) + )[0] + user.save() + self.federated_username = user.federated_username return True else: return False @@ -84,11 +71,14 @@ class CASFederateValidateUser(object): @staticmethod def register_slo(username, session_key, ticket): """association a ticket with a (username, session) for processing later SLO request""" - FederateSLO.objects.create( - username=username, - session_key=session_key, - ticket=ticket - ) + try: + FederateSLO.objects.create( + username=username, + session_key=session_key, + ticket=ticket + ) + except IntegrityError: # pragma: no cover (ignore if the FederateSLO already exists) + pass def clean_sessions(self, logout_request): """process a SLO request""" diff --git a/cas_server/forms.py b/cas_server/forms.py index 233938e..bf7a0e2 100644 --- a/cas_server/forms.py +++ b/cas_server/forms.py @@ -33,16 +33,14 @@ class FederateSelect(forms.Form): Form used on the login page when CAS_FEDERATE is True allowing the user to choose a identity provider. """ - provider = forms.ChoiceField( + provider = forms.ModelChoiceField( + queryset=models.FederatedIendityProvider.objects.all().order_by( + "pos", + "verbose_name", + "suffix" + ), + to_field_name="suffix", label=_('Identity provider'), - # with use a lambda abstraction to delay the access to settings.CAS_FEDERATE_PROVIDERS - # this is usefull to use the override_settings decorator in tests - choices=[ - ( - p, - utils.get_tuple(settings.CAS_FEDERATE_PROVIDERS[p], 2, p) - ) for p in settings.CAS_FEDERATE_PROVIDERS_LIST - ] ) service = forms.CharField(label=_('service'), widget=forms.HiddenInput(), required=False) method = forms.CharField(widget=forms.HiddenInput(), required=False) @@ -88,13 +86,10 @@ class FederateUserCredential(UserCredential): def clean(self): cleaned_data = super(FederateUserCredential, self).clean() try: - component = cleaned_data["username"].split('@') - username = '@'.join(component[:-1]) - provider = component[-1] - user = models.FederatedUser.objects.get(username=username, provider=provider) + user = models.FederatedUser.get_from_federated_username(cleaned_data["username"]) user.ticket = "" user.save() - # should not happed as is the FederatedUser do not exists, super should + # should not happed as if the FederatedUser do not exists, super should # raise before a ValidationError("bad user") except models.FederatedUser.DoesNotExist: # pragma: no cover (should not happend) raise forms.ValidationError( diff --git a/cas_server/locale/en/LC_MESSAGES/django.mo b/cas_server/locale/en/LC_MESSAGES/django.mo index 3ebf9a68d87017e3222c7d49705f72c4480bb712..ac062279497e09c2d1d8f373a76b2b01c87a1880 100644 GIT binary patch delta 1352 zcma*nOGs346vy%7IGUqP=9u=L(HhM%9mht;9?rssLV8fMERaBl5Na};vWI0Y!pIhd z#6T@<6SRn4Q$i5AXw$Z66GVhG5Cu^rt^59*YXxokRD{LlGgF7!4u^4{mZ zZj30gf_PJ4){R#R`NeqPHk-hQcop{+nT_EzR%5Ez>=9l>eWPQZSqujp?_)djuULY0 z{PJTn^8SeJ;$kT``p}2x@(=79E@OTN^}-9Rz-e5BGuVk9k68e>BRSYXBsWVr^E7ra zKZBd`F*f5j^s>GMO3j+M(S%xPKdKU^Q9I0FIo`tvK1VJ11C>Z+S>6UDC+kElunm>) zan!~}QHk9^ifOkn&iZzniyr)p`at)5Gpc0?)O;W6#a>*1{m%1~sD#erI(&lq@CQuN z#vD#C-@!|2-4`6gVRoa9l`N$G>Ro^f_3kysu#~iAH>!7+unVV96>_m1eV`WAx(?*W z63+c3s&&Jtzt14A+9WpOOH}J-eAHj1Y^^l=hzZnAqtsQs>_qi0j_Tc6RB4~07eAp| z_Y<{HuqxlWt*8VKpjtPC5gbLW_Y8T(-c?b5U3}+;N?pubTA&=2aSTb;_Mo2kqDp)S z<9HN%aMF2RwK(58KkD!6Q5y;&)wee1c^s9{&Jixuy9>A;?;@=;4(IPdSFE1kyv-)F z5;qWagwDy_Xmc)>qrR!3O{^ibYZswpfbOz|OvDb~}{GYMa+)zn0)LZp+l_t)hL$j?> zyQsE}zbXo3r`#3p@tm(A+vqv$3ikIUQ|bQnshWYoBPW;)+CO)WWiw?r3reF+Es>gV fbW^x(e2drr*EHMb-CdN;`7Rg!-`7&rLj}J8=$new delta 1323 zcmZwHOGs346vy%7V{Fu>$4qKgKFb`<=VW6jN=hUW#4HM2i6T=Os4=F5ltTn9k{}s0 z5ps$)E}}(uBSD*1Em~BYXd_533)pV=I~$6_4JGkcCx*pA^HX5BdCnMTdMj(#lSqa4eT z@k#6AqLdrwF^J>t1G|L>=r3Ue9n`?DxEFo-X8W)NyRZQ(@CvdQyNm46=Dq#{JV}2A zJMjZHvA%^0%mUo##YT*w7Mew6VH1v`HnM~y z(N^#@KEXlOw@ofIpl_!cS+W?aKY$uIjJxoX_k0?)fmy6a2Q~3J#wp(?Ow;dYkV^I) z=WvGI>mYf1+>(`HQZ;+ag=*#}ud)wSvpd*}FHsp$eKbJ@s$@~*&tl&F5md=$FoFxH zaj%gn>>D1#->8z+gvh^AI1)1ZjJHuIzD!|N!*Nv2rcgC|hDzxVRLM$<+>+Iz7P^2c z*)`Ng=TRkF#Wq|+t@jg|!UDzQUl-MUTPbfxEpW=~PoPqJ9aXcNIEb&j=T#+c$?8$# zBd7y(B3ZW{?|BThfdN#>?qLJ2CApwv#>=?fsD{-NykAB_GAE1RQ9|#>R?~!PI`LLh zn!`jTkxghbHN*+x-`2v7WEme-1n*4d_PnR-*U?WWTzmCfEAWl1ec zsK#}~K|<-*iIwI3g!ga7j&Ob4{hC`#rQWujC@k}S31t4Li9l{%ZbzjBJg zlkvp#=t#V7vZJ6T98V-B5_OZQ#o!sopFfuEEEg_k6+{~&t(7e;9j$Gt?m+q9Zq&IL eIOqHS6wYFBHOD{2?Bl~@aVK5ala+c}()b5Q?0@6{ diff --git a/cas_server/locale/en/LC_MESSAGES/django.po b/cas_server/locale/en/LC_MESSAGES/django.po index 805843a..0ab0f93 100644 --- a/cas_server/locale/en/LC_MESSAGES/django.po +++ b/cas_server/locale/en/LC_MESSAGES/django.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: cas_server\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2016-06-21 00:14+0200\n" -"PO-Revision-Date: 2016-06-21 00:16+0200\n" +"POT-Creation-Date: 2016-07-04 17:15+0200\n" +"PO-Revision-Date: 2016-07-04 17:15+0200\n" "Last-Translator: Valentin Samir \n" "Language-Team: django \n" "Language: en\n" @@ -17,88 +17,135 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "X-Generator: Poedit 1.8.8\n" -#: apps.py:7 templates/cas_server/base.html:3 templates/cas_server/base.html:21 +#: apps.py:19 templates/cas_server/base.html:3 +#: templates/cas_server/base.html:20 msgid "Central Authentication Service" msgstr "Central Authentication Service" -#: forms.py:32 +#: forms.py:43 msgid "Identity provider" msgstr "Identity provider" -#: forms.py:35 forms.py:44 forms.py:92 +#: forms.py:45 forms.py:55 forms.py:106 msgid "service" msgstr "" -#: forms.py:37 +#: forms.py:47 msgid "Remember the identity provider" msgstr "Remember the identity provider" -#: forms.py:38 forms.py:48 +#: forms.py:48 forms.py:59 msgid "warn" msgstr " Warn me before logging me into other sites." -#: forms.py:43 +#: forms.py:54 msgid "login" msgstr "username" -#: forms.py:45 +#: forms.py:56 msgid "password" msgstr "password" -#: forms.py:59 +#: forms.py:71 msgid "Bad user" msgstr "The credentials you provided cannot be determined to be authentic." -#: management/commands/cas_clean_federate.py:13 +#: forms.py:96 +msgid "User not found in the temporary database, please try to reconnect" +msgstr "" + +#: management/commands/cas_clean_federate.py:20 msgid "Clean old federated users" msgstr "Clean old federated users" -#: management/commands/cas_clean_sessions.py:9 +#: management/commands/cas_clean_sessions.py:22 msgid "Clean deleted sessions" msgstr "Clean deleted sessions" -#: management/commands/cas_clean_tickets.py:9 +#: management/commands/cas_clean_tickets.py:22 msgid "Clean old trickets" msgstr "Clean old trickets" -#: models.py:55 +#: models.py:42 +msgid "identity provider" +msgstr "identity provider" + +#: models.py:43 +msgid "identity providers" +msgstr "identity providers" + +#: models.py:47 +msgid "suffix" +msgstr "" + +#: models.py:48 +msgid "" +"Suffix append to backend CAS returner username: `returned_username`@`suffix`" +msgstr "" + +#: models.py:50 +msgid "server url" +msgstr "" + +#: models.py:59 +msgid "CAS protocol version" +msgstr "" + +#: models.py:60 +msgid "" +"Version of the CAS protocol to use when sending requests the the backend CAS" +msgstr "" + +#: models.py:65 +msgid "verbose name" +msgstr "" + +#: models.py:66 +msgid "Name for this identity provider displayed on the login page" +msgstr "" + +#: models.py:70 models.py:312 +msgid "position" +msgstr "position" + +#: models.py:159 msgid "User" msgstr "" -#: models.py:56 +#: models.py:160 msgid "Users" msgstr "" -#: models.py:114 +#: models.py:229 #, python-format msgid "Error during service logout %s" msgstr "Error during service logout %s" -#: models.py:182 +#: models.py:307 msgid "Service pattern" msgstr "Service pattern" -#: models.py:183 +#: models.py:308 msgid "Services patterns" msgstr "" -#: models.py:187 -msgid "position" -msgstr "position" +#: models.py:313 +msgid "service patterns are sorted using the position attribute" +msgstr "" -#: models.py:194 models.py:316 +#: models.py:320 models.py:444 msgid "name" msgstr "name" -#: models.py:195 +#: models.py:321 msgid "A name for the service" msgstr "A name for the service" -#: models.py:200 models.py:344 models.py:362 +#: models.py:326 models.py:473 models.py:492 msgid "pattern" msgstr "pattern" -#: models.py:202 +#: models.py:328 msgid "" "A regular expression matching services. Will usually looks like '^https://" "some\\.server\\.com/path/.*$'.As it is a regular expression, special " @@ -108,73 +155,73 @@ msgstr "" "some\\.server\\.com/path/.*$'.As it is a regular expression, special " "character must be escaped with a '\\'." -#: models.py:211 +#: models.py:337 msgid "user field" msgstr "" -#: models.py:212 +#: models.py:338 msgid "Name of the attribut to transmit as username, empty = login" msgstr "Name of the attribut to transmit as username, empty = login" -#: models.py:216 +#: models.py:342 msgid "restrict username" msgstr "" -#: models.py:217 +#: models.py:343 msgid "Limit username allowed to connect to the list provided bellow" msgstr "Limit username allowed to connect to the list provided bellow" -#: models.py:221 +#: models.py:347 msgid "proxy" msgstr "proxy" -#: models.py:222 +#: models.py:348 msgid "Proxy tickets can be delivered to the service" msgstr "Proxy tickets can be delivered to the service" -#: models.py:226 +#: models.py:352 msgid "proxy callback" msgstr "proxy callback" -#: models.py:227 +#: models.py:353 msgid "can be used as a proxy callback to deliver PGT" msgstr "can be used as a proxy callback to deliver PGT" -#: models.py:231 +#: models.py:357 msgid "single log out" msgstr "" -#: models.py:232 +#: models.py:358 msgid "Enable SLO for the service" msgstr "Enable SLO for the service" -#: models.py:239 +#: models.py:365 msgid "single log out callback" msgstr "" -#: models.py:240 +#: models.py:366 msgid "" "URL where the SLO request will be POST. empty = service url\n" "This is usefull for non HTTP proxied services." msgstr "" -#: models.py:301 +#: models.py:428 msgid "username" msgstr "" -#: models.py:302 +#: models.py:429 msgid "username allowed to connect to the service" msgstr "username allowed to connect to the service" -#: models.py:317 +#: models.py:445 msgid "name of an attribut to send to the service, use * for all attributes" msgstr "name of an attribut to send to the service, use * for all attributes" -#: models.py:322 models.py:368 +#: models.py:450 models.py:498 msgid "replace" msgstr "replace" -#: models.py:323 +#: models.py:451 msgid "" "name under which the attribut will be showto the service. empty = default " "name of the attribut" @@ -182,39 +229,30 @@ msgstr "" "name under which the attribut will be showto the service. empty = default " "name of the attribut" -#: models.py:339 models.py:357 +#: models.py:468 models.py:487 msgid "attribut" msgstr "attribut" -#: models.py:340 +#: models.py:469 msgid "Name of the attribut which must verify pattern" msgstr "Name of the attribut which must verify pattern" -#: models.py:345 +#: models.py:474 msgid "a regular expression" msgstr "a regular expression" -#: models.py:358 +#: models.py:488 msgid "Name of the attribut for which the value must be replace" msgstr "Name of the attribut for which the value must be replace" -#: models.py:363 +#: models.py:493 msgid "An regular expression maching whats need to be replaced" msgstr "An regular expression maching whats need to be replaced" -#: models.py:369 +#: models.py:499 msgid "replace expression, groups are capture by \\1, \\2 …" msgstr "replace expression, groups are capture by \\1, \\2 …" -#: models.py:476 -#, python-format -msgid "" -"Error during service logout %(service)s:\n" -"%(error)s" -msgstr "" -"Error during service logout %(service)s:\n" -"%(error)s" - #: templates/cas_server/logged.html:6 msgid "Logged" msgstr "" @@ -243,7 +281,7 @@ msgstr "Login" msgid "Connect to the service" msgstr "Connect to the service" -#: views.py:140 +#: views.py:152 msgid "" "

Logout successful

You have successfully logged out from the Central " "Authentication Service. For security reasons, exit your web browser." @@ -251,7 +289,7 @@ msgstr "" "

Logout successful

You have successfully logged out from the Central " "Authentication Service. For security reasons, exit your web browser." -#: views.py:146 +#: views.py:158 #, python-format msgid "" "

Logout successful

You have successfully logged out from %s sessions " @@ -262,7 +300,7 @@ msgstr "" "of the Central Authentication Service. For security reasons, exit your web " "browser." -#: views.py:153 +#: views.py:165 msgid "" "

Logout successful

You were already logged out from the Central " "Authentication Service. For security reasons, exit your web browser." @@ -270,48 +308,55 @@ msgstr "" "

Logout successful

You were already logged out from the Central " "Authentication Service. For security reasons, exit your web browser." -#: views.py:294 +#: views.py:349 msgid "Invalid login ticket" msgstr "Invalid login ticket, please retry to login" -#: views.py:410 +#: views.py:470 #, python-format msgid "Authentication has been required by service %(name)s (%(url)s)" msgstr "Authentication has been required by service %(name)s (%(url)s)" -#: views.py:448 +#: views.py:508 #, python-format msgid "Service %(url)s non allowed." msgstr "Service %(url)s non allowed." -#: views.py:455 +#: views.py:515 msgid "Username non allowed" msgstr "Username non allowed" -#: views.py:462 +#: views.py:522 msgid "User charateristics non allowed" msgstr "User charateristics non allowed" -#: views.py:469 +#: views.py:529 #, python-format msgid "The attribut %(field)s is needed to use that service" msgstr "The attribut %(field)s is needed to use that service" -#: views.py:539 +#: views.py:599 #, python-format msgid "Authentication renewal required by service %(name)s (%(url)s)." msgstr "Authentication renewal required by service %(name)s (%(url)s)." -#: views.py:546 +#: views.py:606 #, python-format msgid "Authentication required by service %(name)s (%(url)s)." msgstr "Authentication required by service %(name)s (%(url)s)." -#: views.py:553 +#: views.py:613 #, python-format msgid "Service %s non allowed" msgstr "Service %s non allowed" +#~ msgid "" +#~ "Error during service logout %(service)s:\n" +#~ "%(error)s" +#~ msgstr "" +#~ "Error during service logout %(service)s:\n" +#~ "%(error)s" + #~ msgid "Successfully logout" #~ msgstr "" #~ "

Logout successful

You have successfully logged out of the Central " diff --git a/cas_server/locale/fr/LC_MESSAGES/django.mo b/cas_server/locale/fr/LC_MESSAGES/django.mo index 5b0180af5885870fe08c3dc4b0859370d8b631ad..362bc4caf94487b373335bdc130cf87f19738c44 100644 GIT binary patch delta 2695 zcmZ{kTWl0n7{^bA)>2A=S}eT)M<^8JTI|hAp#>45wMMHHLu@U2IBbBdID56*>e!jIu$xcN?{?uO^#C-67e z0^coGY9qW5bM_3Sn%Q?lj?~Mr0=^CxDwS0ySV*9AunPVN%i*8#c~OZ{)$A8QUQn%Y zF6@O8$b#+gAY25`LTXe$!9w_Jy#F0O!2TL+g{x-DKC6_;Vhsn!;VgIou7;PPBrGk> zE3*m3MVX^eZuSwB1gGJH@Ke|a|AQiRA63#7m4dQ&puD#~K0gSvQt>M+B*AA8 zb=24K7k+>u)z45Qt(ud6uAu~63GaoCkV~t6C<}hcjc8V zu0a2*94tZ)xk1@Hr5=Ypa5Fp!rDs>-eI5GGXP<(L;fwGdcml43UqbQ9byx{Y`B(t! z;0CxE%6XQLE%2R6^xw(iA_vQ0Bhd?}d>6!j>KPo#Uyp~U+h*290H6uPX2UliO9rIk--S!7tGp(t<(z6KZbTf~3r6qE#KVK2M_HSFM8 zBDn!m(1zqt-$7~3b=U@PK)G=fPNr7%D3rJf_cO)ZGBo+sLrjrc4A#OFHE7!{ zqmCWch!m0s;(kmXnMw8{xyUbLT^^B9#dMR0^jzAql(~vo%l{m8A5(ftYa+8qpIVur zRkSGATR1SSqO(V5T*r5YoU|S>UC(lC6)q@R7=BayR(NE_v8JTsYJa!oX)9sczU3c~ z?~kx?b;9y8Y2$#I(2mV_Or3U8maQ{JDjY7U%so=lQP9;BB$L*DZDca0ozT9c2aTa; zWx0jSuIUG^O{BmxUE3Ho+w?$umgpZp8|WDDq96m|p_yN`YuoX4(g_GbF3IYf!x_gl z+ygpc_{N~&nX7arZ9?sHNKV%ra%|fi^25hUYrA&juc@76^tu!>Apu_|uim%Yw6#Z> zmYw45y@BcZUOpKKCtV@jQ`%j3n=Ysz+%w5(p&s2f_^8k)lA z%N9;-*Ei%&lm&%VN82)kCJJ z?%I~;nQRmF@f~^Yu}>S)cT*=!9cjIvf^&V9rwX!p2bkK};{-n6HUe5WOn|_*(w1lV z5{|A*a7ktmEGkJ;<5VdziUgT>ZNenxfaN>#)$r1*l zQ@WT&N>5;uvC#=jSuR#GJr9pRCyz&*z`JQC(+%fWuT4#*s2)3}^H-$nX~*?&kxti> z-Lyv>OY%vuy}{TAKI#O~FJkH|hhLP}ghut&hAAXblg_=#?eTIatD6gU;yV#ktcT#^ y)>B;+G3@wOl6UZ4-k5mM_7TaF=9+0rPT$mY+>YZW{H3P9TEWi@=KlepZXzZC delta 1558 zcmYk+Z%mC*9KiA4Q@5hHu2c%Qd*xqAbxZe_n?EIH!{q;XLBbVESxuPD#Tzp-w!;g< zm|2^dwROiBlJNpB%o{c}ZRQOYvnI27zTX&2VLOAl-FA zC<~}lsICN&bvT&Fg;JR$auw&{Iedqwac{Cn7XHL;9GfCifxWm8e>ql+5(&}oK>p-5 zmt4GyjE~A28adn;#B@v^9iKP{C(#dJ8LmJbcofIu70kdc)WttTA10(S2j(Gbl>(<< zf|c|`ScR=v%=_gQjj`P5!y^2I`l76~ctnDzJ6((8a1ZJN4r4i<#=Y2!E-V}qUq}FT zfvZs$5^?%X$X?|b7V~~N<^01v)CqcU2|mTGIEvNjfJP*$vft?+LY?@ybN?jjg3h8Y z@R@V}EwU#WK%M6^&c-2(>I=PY23dp*@6hjNo1M7ZBXSeRF`Gug5NorEzfN?R8?*2l>Pnv@o0l)B zhw?Y-j@_KpGq?^(w|qdI_$&G`hi#EH38U_C85a%tb_`<^>bOqS53D;%V>ykxj@IP( z1S?TLfZ2sd64isCMIZ9a zj)s0t`Uz>7N1bN3M5a6bB|t+DmPSsCel;5QDypV~^JN#8V+HCWbz{0nvKF-u2\n" "Language-Team: django \n" "Language: fr\n" @@ -18,88 +18,141 @@ msgstr "" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "X-Generator: Poedit 1.8.8\n" -#: apps.py:7 templates/cas_server/base.html:3 templates/cas_server/base.html:21 +#: apps.py:19 templates/cas_server/base.html:3 +#: templates/cas_server/base.html:20 msgid "Central Authentication Service" msgstr "Service Central d'Authentification" -#: forms.py:32 +#: forms.py:43 msgid "Identity provider" msgstr "fournisseur d'identité" -#: forms.py:35 forms.py:44 forms.py:92 +#: forms.py:45 forms.py:55 forms.py:106 msgid "service" msgstr "service" -#: forms.py:37 +#: forms.py:47 msgid "Remember the identity provider" msgstr "Se souvenir du fournisseur d'identité" -#: forms.py:38 forms.py:48 +#: forms.py:48 forms.py:59 msgid "warn" msgstr "Prévenez-moi avant d'accéder à d'autres services." -#: forms.py:43 +#: forms.py:54 msgid "login" msgstr "Identifiant" -#: forms.py:45 +#: forms.py:56 msgid "password" msgstr "mot de passe" -#: forms.py:59 +#: forms.py:71 msgid "Bad user" msgstr "Les informations transmises n'ont pas permis de vous authentifier." -#: management/commands/cas_clean_federate.py:13 +#: forms.py:96 +msgid "User not found in the temporary database, please try to reconnect" +msgstr "" +"Utilisateur non trouvé dans la base de donnée temporaire, essayez de vous " +"reconnecter" + +#: management/commands/cas_clean_federate.py:20 msgid "Clean old federated users" msgstr "Nettoyer les anciens utilisateurs fédéré" -#: management/commands/cas_clean_sessions.py:9 +#: management/commands/cas_clean_sessions.py:22 msgid "Clean deleted sessions" msgstr "Nettoyer les sessions supprimées" -#: management/commands/cas_clean_tickets.py:9 +#: management/commands/cas_clean_tickets.py:22 msgid "Clean old trickets" msgstr "Nettoyer les vieux tickets" -#: models.py:55 +#: models.py:42 +msgid "identity provider" +msgstr "fournisseur d'identité" + +#: models.py:43 +msgid "identity providers" +msgstr "fournisseurs d'identités" + +#: models.py:47 +msgid "suffix" +msgstr "suffixe" + +#: models.py:48 +msgid "" +"Suffix append to backend CAS returner username: `returned_username`@`suffix`" +msgstr "" +"Suffixe ajouté au nom d'utilisateur retourné par le CAS du fournisseur " +"d'identité : `nom retourné`@`suffixe`" + +#: models.py:50 +msgid "server url" +msgstr "url du serveur" + +#: models.py:59 +msgid "CAS protocol version" +msgstr "Version du protocole CAS" + +#: models.py:60 +msgid "" +"Version of the CAS protocol to use when sending requests the the backend CAS" +msgstr "" +"Version du protocole CAS à utiliser lorsque l'on envoie des requête au CAS " +"du fournisseur d'identité" + +#: models.py:65 +msgid "verbose name" +msgstr "Nom du fournisseur" + +#: models.py:66 +msgid "Name for this identity provider displayed on the login page" +msgstr "Nom affiché pour ce fournisseur d'identité sur la page de connexion" + +#: models.py:70 models.py:312 +msgid "position" +msgstr "position" + +#: models.py:159 msgid "User" msgstr "Utilisateur" -#: models.py:56 +#: models.py:160 msgid "Users" msgstr "Utilisateurs" -#: models.py:114 +#: models.py:229 #, python-format msgid "Error during service logout %s" msgstr "Une erreur est survenue durant la déconnexion du service %s" -#: models.py:182 +#: models.py:307 msgid "Service pattern" msgstr "Motif de service" -#: models.py:183 +#: models.py:308 msgid "Services patterns" msgstr "Motifs de services" -#: models.py:187 -msgid "position" -msgstr "position" +#: models.py:313 +msgid "service patterns are sorted using the position attribute" +msgstr "Les motifs de service sont trié selon l'attribut position" -#: models.py:194 models.py:316 +#: models.py:320 models.py:444 msgid "name" msgstr "nom" -#: models.py:195 +#: models.py:321 msgid "A name for the service" msgstr "Un nom pour le service" -#: models.py:200 models.py:344 models.py:362 +#: models.py:326 models.py:473 models.py:492 msgid "pattern" msgstr "motif" -#: models.py:202 +#: models.py:328 msgid "" "A regular expression matching services. Will usually looks like '^https://" "some\\.server\\.com/path/.*$'.As it is a regular expression, special " @@ -110,55 +163,55 @@ msgstr "" "expression rationnelle, les caractères spéciaux doivent être échappés avec " "un '\\'." -#: models.py:211 +#: models.py:337 msgid "user field" msgstr "champ utilisateur" -#: models.py:212 +#: models.py:338 msgid "Name of the attribut to transmit as username, empty = login" msgstr "" "Nom de l'attribut devant être transmis comme nom d'utilisateur au service. " "vide = nom de connection" -#: models.py:216 +#: models.py:342 msgid "restrict username" msgstr "limiter les noms d'utilisateurs" -#: models.py:217 +#: models.py:343 msgid "Limit username allowed to connect to the list provided bellow" msgstr "" "Limiter les noms d'utilisateurs autorisé à se connecter à la liste fournie " "ci-dessous" -#: models.py:221 +#: models.py:347 msgid "proxy" msgstr "proxy" -#: models.py:222 +#: models.py:348 msgid "Proxy tickets can be delivered to the service" msgstr "des proxy tickets peuvent être délivrés au service" -#: models.py:226 +#: models.py:352 msgid "proxy callback" msgstr "" -#: models.py:227 +#: models.py:353 msgid "can be used as a proxy callback to deliver PGT" msgstr "peut être utilisé comme un callback pour recevoir un PGT" -#: models.py:231 +#: models.py:357 msgid "single log out" msgstr "" -#: models.py:232 +#: models.py:358 msgid "Enable SLO for the service" msgstr "Active le SLO pour le service" -#: models.py:239 +#: models.py:365 msgid "single log out callback" msgstr "" -#: models.py:240 +#: models.py:366 msgid "" "URL where the SLO request will be POST. empty = service url\n" "This is usefull for non HTTP proxied services." @@ -167,63 +220,54 @@ msgstr "" "service\n" "Ceci n'est utilise que pour des services non HTTP proxifiés" -#: models.py:301 +#: models.py:428 msgid "username" msgstr "nom d'utilisateur" -#: models.py:302 +#: models.py:429 msgid "username allowed to connect to the service" msgstr "noms d'utilisateurs autorisé à se connecter au service" -#: models.py:317 +#: models.py:445 msgid "name of an attribut to send to the service, use * for all attributes" msgstr "" "nom d'un attribut a envoyer au service, utiliser * pour tous les attributs" -#: models.py:322 models.py:368 +#: models.py:450 models.py:498 msgid "replace" msgstr "remplacement" -#: models.py:323 +#: models.py:451 msgid "" "name under which the attribut will be showto the service. empty = default " "name of the attribut" msgstr "" "nom sous lequel l'attribut sera rendu visible au service. vide = inchangé" -#: models.py:339 models.py:357 +#: models.py:468 models.py:487 msgid "attribut" msgstr "attribut" -#: models.py:340 +#: models.py:469 msgid "Name of the attribut which must verify pattern" msgstr "Nom de l'attribut devant vérifier un motif" -#: models.py:345 +#: models.py:474 msgid "a regular expression" msgstr "une expression régulière" -#: models.py:358 +#: models.py:488 msgid "Name of the attribut for which the value must be replace" msgstr "nom de l'attribue pour lequel la valeur doit être remplacé" -#: models.py:363 +#: models.py:493 msgid "An regular expression maching whats need to be replaced" msgstr "une expression régulière reconnaissant ce qui doit être remplacé" -#: models.py:369 +#: models.py:499 msgid "replace expression, groups are capture by \\1, \\2 …" msgstr "expression de remplacement, les groupe sont capturé par \\1, \\2" -#: models.py:476 -#, python-format -msgid "" -"Error during service logout %(service)s:\n" -"%(error)s" -msgstr "" -"Une erreur est survenue durant la déconnexion du service %(service)s:" -"%(error)s" - #: templates/cas_server/logged.html:6 msgid "Logged" msgstr "" @@ -252,7 +296,7 @@ msgstr "Connexion" msgid "Connect to the service" msgstr "Se connecter au service" -#: views.py:140 +#: views.py:152 msgid "" "

Logout successful

You have successfully logged out from the Central " "Authentication Service. For security reasons, exit your web browser." @@ -261,7 +305,7 @@ msgstr "" "d'Authentification. Pour des raisons de sécurité, veuillez fermer votre " "navigateur." -#: views.py:146 +#: views.py:158 #, python-format msgid "" "

Logout successful

You have successfully logged out from %s sessions " @@ -272,7 +316,7 @@ msgstr "" "Service Central d'Authentification. Pour des raisons de sécurité, veuillez " "fermer votre navigateur." -#: views.py:153 +#: views.py:165 msgid "" "

Logout successful

You were already logged out from the Central " "Authentication Service. For security reasons, exit your web browser." @@ -281,50 +325,57 @@ msgstr "" "d'Authentification. Pour des raisons de sécurité, veuillez fermer votre " "navigateur." -#: views.py:294 +#: views.py:349 msgid "Invalid login ticket" msgstr "Ticket de connexion invalide, merci de réessayé de vous connecter" -#: views.py:410 +#: views.py:470 #, python-format msgid "Authentication has been required by service %(name)s (%(url)s)" msgstr "" "Une demande d'authentification a été émise pour le service %(name)s " "(%(url)s)." -#: views.py:448 +#: views.py:508 #, python-format msgid "Service %(url)s non allowed." msgstr "le service %(url)s n'est pas autorisé." -#: views.py:455 +#: views.py:515 msgid "Username non allowed" msgstr "Nom d'utilisateur non authorisé" -#: views.py:462 +#: views.py:522 msgid "User charateristics non allowed" msgstr "Caractéristique utilisateur non autorisée" -#: views.py:469 +#: views.py:529 #, python-format msgid "The attribut %(field)s is needed to use that service" msgstr "L'attribut %(field)s est nécessaire pour se connecter à ce service" -#: views.py:539 +#: views.py:599 #, python-format msgid "Authentication renewal required by service %(name)s (%(url)s)." msgstr "Demande de réauthentification pour le service %(name)s (%(url)s)." -#: views.py:546 +#: views.py:606 #, python-format msgid "Authentication required by service %(name)s (%(url)s)." msgstr "Authentification requise par le service %(name)s (%(url)s)." -#: views.py:553 +#: views.py:613 #, python-format msgid "Service %s non allowed" msgstr "Le service %s n'est pas autorisé" +#~ msgid "" +#~ "Error during service logout %(service)s:\n" +#~ "%(error)s" +#~ msgstr "" +#~ "Une erreur est survenue durant la déconnexion du service %(service)s:" +#~ "%(error)s" + #~ msgid "Successfully logout" #~ msgstr "" #~ "

Déconnexion réussie

\n" diff --git a/cas_server/migrations/0007_auto_20160704_1510.py b/cas_server/migrations/0007_auto_20160704_1510.py new file mode 100644 index 0000000..a89627d --- /dev/null +++ b/cas_server/migrations/0007_auto_20160704_1510.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-07-04 15:10 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('cas_server', '0006_auto_20160623_1516'), + ] + + operations = [ + migrations.CreateModel( + name='FederatedIendityProvider', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('suffix', models.CharField(help_text='Suffix append to backend CAS returner username: `returned_username`@`suffix`', max_length=30, unique=True, verbose_name='suffix')), + ('server_url', models.CharField(max_length=255, verbose_name='server url')), + ('cas_protocol_version', models.CharField(choices=[(b'1', b'CAS 1.0'), (b'2', b'CAS 2.0'), (b'3', b'CAS 3.0'), (b'CAS_2_SAML_1_0', b'SAML 1.1')], default=b'3', help_text='Version of the CAS protocol to use when sending requests the the backend CAS', max_length=30, verbose_name='CAS protocol version')), + ('verbose_name', models.CharField(help_text='Name for this identity provider displayed on the login page', max_length=255, verbose_name='verbose name')), + ('pos', models.IntegerField(default=100, help_text='Identity provider are sorted using the (position, verbose name, suffix) attributes', verbose_name='position')), + ], + options={ + 'verbose_name': 'identity provider', + 'verbose_name_plural': 'identity providers', + }, + ), + migrations.AlterField( + model_name='federateduser', + name='provider', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cas_server.FederatedIendityProvider'), + ), + migrations.AlterField( + model_name='federateslo', + name='ticket', + field=models.CharField(db_index=True, max_length=255), + ), + migrations.AlterField( + model_name='servicepattern', + name='pos', + field=models.IntegerField(default=100, help_text='service patterns are sorted using the position attribute', verbose_name='position'), + ), + migrations.AlterUniqueTogether( + name='federateslo', + unique_together=set([('username', 'session_key', 'ticket')]), + ), + ] diff --git a/cas_server/models.py b/cas_server/models.py index 17c4d83..971cce5 100644 --- a/cas_server/models.py +++ b/cas_server/models.py @@ -17,6 +17,7 @@ from django.db.models import Q from django.contrib import messages from django.utils.translation import ugettext_lazy as _ from django.utils import timezone +from django.utils.encoding import python_2_unicode_compatible from picklefield.fields import PickledObjectField import re @@ -34,18 +35,93 @@ SessionStore = import_module(settings.SESSION_ENGINE).SessionStore logger = logging.getLogger(__name__) +@python_2_unicode_compatible +class FederatedIendityProvider(models.Model): + """An identity provider for the federated mode""" + class Meta: + verbose_name = _("identity provider") + verbose_name_plural = _("identity providers") + suffix = models.CharField( + max_length=30, + unique=True, + verbose_name=_(u"suffix"), + help_text=_("Suffix append to backend CAS returner username: `returned_username`@`suffix`") + ) + server_url = models.CharField(max_length=255, verbose_name=_(u"server url")) + cas_protocol_version = models.CharField( + max_length=30, + choices=[ + ("1", "CAS 1.0"), + ("2", "CAS 2.0"), + ("3", "CAS 3.0"), + ("CAS_2_SAML_1_0", "SAML 1.1") + ], + verbose_name=_(u"CAS protocol version"), + help_text=_("Version of the CAS protocol to use when sending requests the the backend CAS"), + default="3" + ) + verbose_name = models.CharField( + max_length=255, + verbose_name=_(u"verbose name"), + help_text=_("Name for this identity provider displayed on the login page") + ) + pos = models.IntegerField( + default=100, + verbose_name=_(u"position"), + help_text=_( + ( + u"Identity provider are sorted using the " + u"(position, verbose name, suffix) attributes" + ) + ) + ) + + def __str__(self): + return self.verbose_name + + @staticmethod + def build_username_from_suffix(username, suffix): + """Transform backend username into federated username using `suffix`""" + return u'%s@%s' % (username, suffix) + + def build_username(self, username): + """Transform backend username into federated username""" + return u'%s@%s' % (username, self.suffix) + + +@python_2_unicode_compatible class FederatedUser(models.Model): """A federated user as returner by a CAS provider (username and attributes)""" class Meta: unique_together = ("username", "provider") username = models.CharField(max_length=124) - provider = models.CharField(max_length=124) + provider = models.ForeignKey(FederatedIendityProvider, on_delete=models.CASCADE) attributs = PickledObjectField() ticket = models.CharField(max_length=255) last_update = models.DateTimeField(auto_now=True) - def __unicode__(self): - return u"%s@%s" % (self.username, self.provider) + def __str__(self): + return self.federated_username + + @property + def federated_username(self): + """return the federated username with a suffix""" + return self.provider.build_username(self.username) + + @classmethod + def get_from_federated_username(cls, username): + """return a FederatedUser object from a federated username""" + if username is None: + raise cls.DoesNotExist() + else: + component = username.split('@') + username = '@'.join(component[:-1]) + suffix = component[-1] + try: + provider = FederatedIendityProvider.objects.get(suffix=suffix) + return cls.objects.get(username=username, provider=provider) + except FederatedIendityProvider.DoesNotExist: + raise cls.DoesNotExist() @classmethod def clean_old_entries(cls): @@ -55,17 +131,17 @@ class FederatedUser(models.Model): ) known_users = {user.username for user in User.objects.all()} for user in federated_users: - if not ('%s@%s' % (user.username, user.provider)) in known_users: + if user.federated_username not in known_users: user.delete() class FederateSLO(models.Model): """An association between a CAS provider ticket and a (username, session) for processing SLO""" class Meta: - unique_together = ("username", "session_key") + unique_together = ("username", "session_key", "ticket") username = models.CharField(max_length=30) session_key = models.CharField(max_length=40, blank=True, null=True) - ticket = models.CharField(max_length=255) + ticket = models.CharField(max_length=255, db_index=True) @classmethod def clean_deleted_sessions(cls): @@ -75,6 +151,7 @@ class FederateSLO(models.Model): federate_slo.delete() +@python_2_unicode_compatible class User(models.Model): """A user logged into the CAS""" class Meta: @@ -117,7 +194,7 @@ class User(models.Model): """return a fresh dict for the user attributs""" return utils.import_attr(settings.CAS_AUTH_CLASS)(self.username).attributs() - def __unicode__(self): + def __str__(self): return u"%s - %s" % (self.username, self.session_key) def logout(self, request=None): @@ -222,6 +299,7 @@ class UserFieldNotDefined(ServicePatternException): pass +@python_2_unicode_compatible class ServicePattern(models.Model): """Allowed services pattern agains services are tested to""" class Meta: @@ -231,7 +309,8 @@ class ServicePattern(models.Model): pos = models.IntegerField( default=100, - verbose_name=_(u"position") + verbose_name=_(u"position"), + help_text=_(u"service patterns are sorted using the position attribute") ) name = models.CharField( max_length=255, @@ -288,7 +367,7 @@ class ServicePattern(models.Model): u"This is usefull for non HTTP proxied services.") ) - def __unicode__(self): + def __str__(self): return u"%s: %s" % (self.pos, self.pattern) def check_user(self, user): @@ -341,6 +420,7 @@ class ServicePattern(models.Model): raise cls.DoesNotExist() +@python_2_unicode_compatible class Username(models.Model): """A list of allowed usernames on a service pattern""" value = models.CharField( @@ -350,10 +430,11 @@ class Username(models.Model): ) service_pattern = models.ForeignKey(ServicePattern, related_name="usernames") - def __unicode__(self): + def __str__(self): return self.value +@python_2_unicode_compatible class ReplaceAttributName(models.Model): """A list of replacement of attributs name for a service pattern""" class Meta: @@ -372,13 +453,14 @@ class ReplaceAttributName(models.Model): ) service_pattern = models.ForeignKey(ServicePattern, related_name="attributs") - def __unicode__(self): + def __str__(self): if not self.replace: return self.name else: return u"%s → %s" % (self.name, self.replace) +@python_2_unicode_compatible class FilterAttributValue(models.Model): """A list of filter on attributs for a service pattern""" attribut = models.CharField( @@ -393,10 +475,11 @@ class FilterAttributValue(models.Model): ) service_pattern = models.ForeignKey(ServicePattern, related_name="filters") - def __unicode__(self): + def __str__(self): return u"%s %s" % (self.attribut, self.pattern) +@python_2_unicode_compatible class ReplaceAttributValue(models.Model): """Replacement to apply on attributs values for a service pattern""" attribut = models.CharField( @@ -417,10 +500,11 @@ class ReplaceAttributValue(models.Model): ) service_pattern = models.ForeignKey(ServicePattern, related_name="replacements") - def __unicode__(self): + def __str__(self): return u"%s %s %s" % (self.attribut, self.pattern, self.replace) +@python_2_unicode_compatible class Ticket(models.Model): """Generic class for a Ticket""" class Meta: @@ -437,7 +521,7 @@ class Ticket(models.Model): VALIDITY = settings.CAS_TICKET_VALIDITY TIMEOUT = settings.CAS_TICKET_TIMEOUT - def __unicode__(self): + def __str__(self): return u"Ticket-%s" % self.pk @classmethod @@ -507,34 +591,38 @@ class Ticket(models.Model): ) +@python_2_unicode_compatible 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): + def __str__(self): return u"ServiceTicket-%s" % self.pk +@python_2_unicode_compatible 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): + def __str__(self): return u"ProxyTicket-%s" % self.pk +@python_2_unicode_compatible class ProxyGrantingTicket(Ticket): """A Proxy Granting Ticket""" PREFIX = settings.CAS_PROXY_GRANTING_TICKET_PREFIX VALIDITY = settings.CAS_PGT_VALIDITY value = models.CharField(max_length=255, default=utils.gen_pgt, unique=True) - def __unicode__(self): + def __str__(self): return u"ProxyGrantingTicket-%s" % self.pk +@python_2_unicode_compatible class Proxy(models.Model): """A list of proxies on `ProxyTicket`""" class Meta: @@ -542,5 +630,5 @@ class Proxy(models.Model): url = models.CharField(max_length=255) proxy_ticket = models.ForeignKey(ProxyTicket, related_name="proxies") - def __unicode__(self): + def __str__(self): return self.url diff --git a/cas_server/tests/mixin.py b/cas_server/tests/mixin.py index 09ddadc..859c3a0 100644 --- a/cas_server/tests/mixin.py +++ b/cas_server/tests/mixin.py @@ -23,27 +23,28 @@ from cas_server.tests.utils import get_auth_client class BaseServicePattern(object): """Mixing for setting up service pattern for testing""" - def setup_service_patterns(self, proxy=False): + @classmethod + def setup_service_patterns(cls, proxy=False): """setting up service pattern""" # For general purpose testing - self.service = "https://www.example.com" - self.service_pattern = models.ServicePattern.objects.create( + cls.service = "https://www.example.com" + cls.service_pattern = models.ServicePattern.objects.create( name="example", pattern="^https://www\.example\.com(/.*)?$", proxy=proxy, ) - models.ReplaceAttributName.objects.create(name="*", service_pattern=self.service_pattern) + models.ReplaceAttributName.objects.create(name="*", service_pattern=cls.service_pattern) # For testing the restrict_users attributes - self.service_restrict_user_fail = "https://restrict_user_fail.example.com" - self.service_pattern_restrict_user_fail = models.ServicePattern.objects.create( + cls.service_restrict_user_fail = "https://restrict_user_fail.example.com" + cls.service_pattern_restrict_user_fail = models.ServicePattern.objects.create( name="restrict_user_fail", pattern="^https://restrict_user_fail\.example\.com(/.*)?$", restrict_users=True, proxy=proxy, ) - self.service_restrict_user_success = "https://restrict_user_success.example.com" - self.service_pattern_restrict_user_success = models.ServicePattern.objects.create( + cls.service_restrict_user_success = "https://restrict_user_success.example.com" + cls.service_pattern_restrict_user_success = models.ServicePattern.objects.create( name="restrict_user_success", pattern="^https://restrict_user_success\.example\.com(/.*)?$", restrict_users=True, @@ -51,12 +52,12 @@ class BaseServicePattern(object): ) models.Username.objects.create( value=settings.CAS_TEST_USER, - service_pattern=self.service_pattern_restrict_user_success + service_pattern=cls.service_pattern_restrict_user_success ) # For testing the user attributes filtering conditions - self.service_filter_fail = "https://filter_fail.example.com" - self.service_pattern_filter_fail = models.ServicePattern.objects.create( + cls.service_filter_fail = "https://filter_fail.example.com" + cls.service_pattern_filter_fail = models.ServicePattern.objects.create( name="filter_fail", pattern="^https://filter_fail\.example\.com(/.*)?$", proxy=proxy, @@ -64,10 +65,10 @@ class BaseServicePattern(object): models.FilterAttributValue.objects.create( attribut="right", pattern="^admin$", - service_pattern=self.service_pattern_filter_fail + service_pattern=cls.service_pattern_filter_fail ) - self.service_filter_fail_alt = "https://filter_fail_alt.example.com" - self.service_pattern_filter_fail_alt = models.ServicePattern.objects.create( + cls.service_filter_fail_alt = "https://filter_fail_alt.example.com" + cls.service_pattern_filter_fail_alt = models.ServicePattern.objects.create( name="filter_fail_alt", pattern="^https://filter_fail_alt\.example\.com(/.*)?$", proxy=proxy, @@ -75,10 +76,10 @@ class BaseServicePattern(object): models.FilterAttributValue.objects.create( attribut="nom", pattern="^toto$", - service_pattern=self.service_pattern_filter_fail_alt + service_pattern=cls.service_pattern_filter_fail_alt ) - self.service_filter_success = "https://filter_success.example.com" - self.service_pattern_filter_success = models.ServicePattern.objects.create( + cls.service_filter_success = "https://filter_success.example.com" + cls.service_pattern_filter_success = models.ServicePattern.objects.create( name="filter_success", pattern="^https://filter_success\.example\.com(/.*)?$", proxy=proxy, @@ -86,26 +87,26 @@ class BaseServicePattern(object): models.FilterAttributValue.objects.create( attribut="email", pattern="^%s$" % re.escape(settings.CAS_TEST_ATTRIBUTES['email']), - service_pattern=self.service_pattern_filter_success + service_pattern=cls.service_pattern_filter_success ) # For testing the user_field attributes - self.service_field_needed_fail = "https://field_needed_fail.example.com" - self.service_pattern_field_needed_fail = models.ServicePattern.objects.create( + cls.service_field_needed_fail = "https://field_needed_fail.example.com" + cls.service_pattern_field_needed_fail = models.ServicePattern.objects.create( name="field_needed_fail", pattern="^https://field_needed_fail\.example\.com(/.*)?$", user_field="uid", proxy=proxy, ) - self.service_field_needed_success = "https://field_needed_success.example.com" - self.service_pattern_field_needed_success = models.ServicePattern.objects.create( + cls.service_field_needed_success = "https://field_needed_success.example.com" + cls.service_pattern_field_needed_success = models.ServicePattern.objects.create( name="field_needed_success", pattern="^https://field_needed_success\.example\.com(/.*)?$", user_field="alias", proxy=proxy, ) - self.service_field_needed_success_alt = "https://field_needed_success_alt.example.com" - self.service_pattern_field_needed_success = models.ServicePattern.objects.create( + cls.service_field_needed_success_alt = "https://field_needed_success_alt.example.com" + cls.service_pattern_field_needed_success = models.ServicePattern.objects.create( name="field_needed_success_alt", pattern="^https://field_needed_success_alt\.example\.com(/.*)?$", user_field="nom", @@ -238,3 +239,17 @@ class CanLogin(object): self.assertTrue(client.session.get("username") is None) self.assertTrue(client.session.get("warn") is None) self.assertTrue(client.session.get("authenticated") is None) + + +class FederatedIendityProviderModel(object): + """Mixin for test classes using the FederatedIendityProvider model""" + @staticmethod + def setup_federated_identity_provider(providers): + """setting up federated identity providers""" + for suffix, (server_url, cas_protocol_version, verbose_name) in providers.items(): + models.FederatedIendityProvider.objects.create( + suffix=suffix, + server_url=server_url, + cas_protocol_version=cas_protocol_version, + verbose_name=verbose_name + ) diff --git a/cas_server/tests/test_federate.py b/cas_server/tests/test_federate.py index 2fe4728..a33feed 100644 --- a/cas_server/tests/test_federate.py +++ b/cas_server/tests/test_federate.py @@ -19,43 +19,37 @@ from django.test.utils import override_settings from six.moves import reload_module -from cas_server import utils, forms -from cas_server.tests.mixin import BaseServicePattern, CanLogin +from cas_server import utils, models +from cas_server.tests.mixin import BaseServicePattern, CanLogin, FederatedIendityProviderModel from cas_server.tests import utils as tests_utils PROVIDERS = { - "example.com": ("http://127.0.0.1:8080", 1, "Example dot com"), - "example.org": ("http://127.0.0.1:8081", 2, "Example dot org"), - "example.net": ("http://127.0.0.1:8082", 3, "Example dot net"), - "example.test": ("http://127.0.0.1:8083", 'CAS_2_SAML_1_0'), + "example.com": ("http://127.0.0.1:8080", '1', "Example dot com"), + "example.org": ("http://127.0.0.1:8081", '2', "Example dot org"), + "example.net": ("http://127.0.0.1:8082", '3', "Example dot net"), + "example.test": ("http://127.0.0.1:8083", 'CAS_2_SAML_1_0', 'Example fot test'), } -PROVIDERS_LIST = list(PROVIDERS.keys()) -PROVIDERS_LIST.sort() - @override_settings( CAS_FEDERATE=True, - CAS_FEDERATE_PROVIDERS=PROVIDERS, - CAS_FEDERATE_PROVIDERS_LIST=PROVIDERS_LIST, CAS_AUTH_CLASS="cas_server.auth.CASFederateAuth", # test with a non ascii username CAS_TEST_USER=u"dédé" ) -class FederateAuthLoginLogoutTestCase(TestCase, BaseServicePattern, CanLogin): +class FederateAuthLoginLogoutTestCase( + TestCase, BaseServicePattern, CanLogin, FederatedIendityProviderModel +): """tests for the views login logout and federate then the federated mode is enabled""" def setUp(self): """Prepare the test context""" self.setup_service_patterns() - reload_module(forms) + self.setup_federated_identity_provider(PROVIDERS) def test_default_settings(self): """default settings should populated some default variable then CAS_FEDERATE is True""" - provider_list = settings.CAS_FEDERATE_PROVIDERS_LIST - del settings.CAS_FEDERATE_PROVIDERS_LIST del settings.CAS_AUTH_CLASS reload_module(default_settings) - self.assertEqual(settings.CAS_FEDERATE_PROVIDERS_LIST, provider_list) self.assertEqual(settings.CAS_AUTH_CLASS, "cas_server.auth.CASFederateAuth") def test_login_get_provider(self): @@ -63,10 +57,10 @@ class FederateAuthLoginLogoutTestCase(TestCase, BaseServicePattern, CanLogin): client = Client() response = client.get("/login") self.assertEqual(response.status_code, 200) - for key, value in settings.CAS_FEDERATE_PROVIDERS.items(): + for provider in models.FederatedIendityProvider.objects.all(): self.assertTrue('' % ( - key, - utils.get_tuple(value, 2, key) + provider.suffix, + provider.verbose_name ) in response.content.decode("utf-8")) self.assertEqual(response.context['post_url'], '/federate') @@ -74,10 +68,11 @@ class FederateAuthLoginLogoutTestCase(TestCase, BaseServicePattern, CanLogin): """test a successful login wrokflow""" tickets = [] # choose the example.com provider - for (provider, cas_port) in [ + for (suffix, cas_port) in [ ("example.com", 8080), ("example.org", 8081), ("example.net", 8082), ("example.test", 8083) ]: + provider = models.FederatedIendityProvider.objects.get(suffix=suffix) # get a bare client client = Client() # fetch the login page @@ -86,7 +81,7 @@ class FederateAuthLoginLogoutTestCase(TestCase, BaseServicePattern, CanLogin): self.assertEqual(response.context['post_url'], '/federate') # get current form parameter params = tests_utils.copy_form(response.context["form"]) - params['provider'] = provider + params['provider'] = provider.suffix if remember: params['remember'] = 'on' # post the choosed provider @@ -96,22 +91,22 @@ class FederateAuthLoginLogoutTestCase(TestCase, BaseServicePattern, CanLogin): if remember: self.assertEqual(response["Location"], '%s/federate/%s?remember=on' % ( 'http://testserver' if django.VERSION < (1, 9) else "", - provider + provider.suffix )) else: self.assertEqual(response["Location"], '%s/federate/%s' % ( 'http://testserver' if django.VERSION < (1, 9) else "", - provider + provider.suffix )) # let's follow the redirect - response = client.get('/federate/%s' % provider) + response = client.get('/federate/%s' % provider.suffix) # we are redirected to the provider CAS for authentication self.assertEqual(response.status_code, 302) self.assertEqual( response["Location"], "%s/login?service=http%%3A%%2F%%2Ftestserver%%2Ffederate%%2F%s" % ( - settings.CAS_FEDERATE_PROVIDERS[provider][0], - provider + provider.server_url, + provider.suffix ) ) # let's generate a ticket @@ -119,7 +114,7 @@ class FederateAuthLoginLogoutTestCase(TestCase, BaseServicePattern, CanLogin): # we lauch a dummy CAS server that only validate once for the service # http://testserver/federate/example.com with `ticket` tests_utils.DummyCAS.run( - ("http://testserver/federate/%s" % provider).encode("ascii"), + ("http://testserver/federate/%s" % provider.suffix).encode("ascii"), ticket.encode("ascii"), settings.CAS_TEST_USER.encode("utf8"), [], @@ -127,7 +122,7 @@ class FederateAuthLoginLogoutTestCase(TestCase, BaseServicePattern, CanLogin): ) # we normally provide a good ticket and should be redirected to /login as the ticket # get successfully validated again the dummy CAS - response = client.get('/federate/%s' % provider, {'ticket': ticket}) + response = client.get('/federate/%s' % provider.suffix, {'ticket': ticket}) self.assertEqual(response.status_code, 302) self.assertEqual(response["Location"], "%s/login" % ( 'http://testserver' if django.VERSION < (1, 9) else "" @@ -143,7 +138,7 @@ class FederateAuthLoginLogoutTestCase(TestCase, BaseServicePattern, CanLogin): response = client.post("/login", params) # the user should now being authenticated using username test@`provider` self.assert_logged( - client, response, username='%s@%s' % (settings.CAS_TEST_USER, provider) + client, response, username=provider.build_username(settings.CAS_TEST_USER) ) tickets.append((provider, ticket, client)) @@ -198,7 +193,7 @@ class FederateAuthLoginLogoutTestCase(TestCase, BaseServicePattern, CanLogin): self.assertEqual( response["Location"], "%s/login?service=http%%3A%%2F%%2Ftestserver%%2Ffederate%%2F%s" % ( - settings.CAS_FEDERATE_PROVIDERS[good_provider][0], + models.FederatedIendityProvider.objects.get(suffix=good_provider).server_url, good_provider ) ) @@ -216,7 +211,7 @@ class FederateAuthLoginLogoutTestCase(TestCase, BaseServicePattern, CanLogin): self.assertEqual( response["Location"], "%s/login?service=http%%3A%%2F%%2Ftestserver%%2Ffederate%%2F%s" % ( - settings.CAS_FEDERATE_PROVIDERS[good_provider][0], + models.FederatedIendityProvider.objects.get(suffix=good_provider).server_url, good_provider ) ) @@ -234,45 +229,45 @@ class FederateAuthLoginLogoutTestCase(TestCase, BaseServicePattern, CanLogin): for (provider, ticket, client) in tickets: # SLO for an unkown ticket should do nothing response = client.post( - "/federate/%s" % provider, + "/federate/%s" % provider.suffix, {'logoutRequest': tests_utils.logout_request(utils.gen_st())} ) self.assertEqual(response.status_code, 200) self.assertEqual(response.content, b"ok") # Bad SLO format should do nothing response = client.post( - "/federate/%s" % provider, + "/federate/%s" % provider.suffix, {'logoutRequest': ""} ) self.assertEqual(response.status_code, 200) self.assertEqual(response.content, b"ok") # Bad SLO format should do nothing response = client.post( - "/federate/%s" % provider, + "/federate/%s" % provider.suffix, {'logoutRequest': ""} ) self.assertEqual(response.status_code, 200) self.assertEqual(response.content, b"ok") response = client.get("/login") self.assert_logged( - client, response, username='%s@%s' % (settings.CAS_TEST_USER, provider) + client, response, username=provider.build_username(settings.CAS_TEST_USER) ) # SLO for a previously logged ticket should log out the user if CAS version is # 3 or 'CAS_2_SAML_1_0' response = client.post( - "/federate/%s" % provider, + "/federate/%s" % provider.suffix, {'logoutRequest': tests_utils.logout_request(ticket)} ) self.assertEqual(response.status_code, 200) self.assertEqual(response.content, b"ok") response = client.get("/login") - if settings.CAS_FEDERATE_PROVIDERS[provider][1] in {3, 'CAS_2_SAML_1_0'}: # support SLO + if provider.cas_protocol_version in {'3', 'CAS_2_SAML_1_0'}: # support SLO self.assert_login_failed(client, response) else: self.assert_logged( - client, response, username='%s@%s' % (settings.CAS_TEST_USER, provider) + client, response, username=provider.build_username(settings.CAS_TEST_USER) ) def test_federate_logout(self): @@ -287,7 +282,7 @@ class FederateAuthLoginLogoutTestCase(TestCase, BaseServicePattern, CanLogin): self.assertEqual(response.status_code, 302) self.assertEqual( response["Location"], - "%s/logout" % settings.CAS_FEDERATE_PROVIDERS[provider][0] + "%s/logout" % provider.server_url, ) response = client.get("/login") self.assert_login_failed(client, response) @@ -326,7 +321,7 @@ class FederateAuthLoginLogoutTestCase(TestCase, BaseServicePattern, CanLogin): self.assertEqual(response.status_code, 302) self.assertEqual(response["Location"], "%s/federate/%s" % ( 'http://testserver' if django.VERSION < (1, 9) else "", - provider + provider.suffix )) def test_login_bad_ticket(self): @@ -338,7 +333,10 @@ class FederateAuthLoginLogoutTestCase(TestCase, BaseServicePattern, CanLogin): # get a bare client client = Client() session = client.session - session["federate_username"] = '%s@%s' % (settings.CAS_TEST_USER, provider) + session["federate_username"] = models.FederatedIendityProvider.build_username_from_suffix( + settings.CAS_TEST_USER, + provider + ) session["federate_ticket"] = utils.gen_st() if django.VERSION >= (1, 8): session.save() @@ -351,9 +349,12 @@ class FederateAuthLoginLogoutTestCase(TestCase, BaseServicePattern, CanLogin): # POST, as (username, ticket) are not valid, we should get the federate login page response = client.post("/login", params) self.assertEqual(response.status_code, 200) - for key, value in settings.CAS_FEDERATE_PROVIDERS.items(): - self.assertTrue('' % ( - key, - utils.get_tuple(value, 2, key) - ) in response.content.decode("utf-8")) + for provider in models.FederatedIendityProvider.objects.all(): + self.assertIn( + '' % ( + provider.suffix, + provider.verbose_name + ), + response.content.decode("utf-8") + ) self.assertEqual(response.context['post_url'], '/federate') diff --git a/cas_server/tests/test_models.py b/cas_server/tests/test_models.py index e027429..7a4403c 100644 --- a/cas_server/tests/test_models.py +++ b/cas_server/tests/test_models.py @@ -22,32 +22,39 @@ from importlib import import_module from cas_server import models, utils from cas_server.tests.utils import get_auth_client, HttpParamsHandler -from cas_server.tests.mixin import UserModels, BaseServicePattern +from cas_server.tests.mixin import UserModels, BaseServicePattern, FederatedIendityProviderModel +from cas_server.tests.test_federate import PROVIDERS SessionStore = import_module(settings.SESSION_ENGINE).SessionStore -class FederatedUserTestCase(TestCase, UserModels): +class FederatedUserTestCase(TestCase, UserModels, FederatedIendityProviderModel): """test for the federated user model""" + def setUp(self): + """Prepare the test context""" + self.setup_federated_identity_provider(PROVIDERS) + def test_clean_old_entries(self): """tests for clean_old_entries that should delete federated user no longer used""" client = Client() client.get("/login") + provider = models.FederatedIendityProvider.objects.get(suffix="example.com") models.FederatedUser.objects.create( - username="test1", provider="example.com", attributs={}, ticket="" + username="test1", provider=provider, attributs={}, ticket="" ) models.FederatedUser.objects.create( - username="test2", provider="example.com", attributs={}, ticket="" + username="test2", provider=provider, attributs={}, ticket="" ) models.FederatedUser.objects.all().update( last_update=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_TIMEOUT + 10)) ) models.FederatedUser.objects.create( - username="test3", provider="example.com", attributs={}, ticket="" + username="test3", provider=provider, attributs={}, ticket="" ) models.User.objects.create( username="test1@example.com", session_key=client.session.session_key ) + self.assertEqual(len(models.FederatedUser.objects.all()), 3) models.FederatedUser.clean_old_entries() self.assertEqual(len(models.FederatedUser.objects.all()), 2) with self.assertRaises(models.FederatedUser.DoesNotExist): diff --git a/cas_server/views.py b/cas_server/views.py index 55ecec4..a95a6cd 100644 --- a/cas_server/views.py +++ b/cas_server/views.py @@ -22,6 +22,7 @@ from django.utils import timezone from django.views.decorators.csrf import csrf_exempt from django.middleware.csrf import CsrfViewMiddleware from django.views.generic import View +from django.utils.encoding import python_2_unicode_compatible import re import logging @@ -37,7 +38,7 @@ import cas_server.models as models from .utils import json_response from .models import ServiceTicket, ProxyTicket, ProxyGrantingTicket -from .models import ServicePattern +from .models import ServicePattern, FederatedIendityProvider, FederatedUser from .federate import CASFederateValidateUser SessionStore = import_module(settings.SESSION_ENGINE).SessionStore @@ -123,11 +124,12 @@ class LogoutView(View, LogoutMixin): self.init_get(request) # if CAS federation mode is enable, bakup the provider before flushing the sessions if settings.CAS_FEDERATE: - if "username" in self.request.session: - component = self.request.session["username"].split('@') - provider = component[-1] - auth = CASFederateValidateUser(provider, service_url="") - else: + try: + user = FederatedUser.get_from_federated_username( + self.request.session.get("username") + ) + auth = CASFederateValidateUser(user.provider, service_url="") + except FederatedUser.DoesNotExist: auth = None session_nb = self.logout(self.request.GET.get("all")) # if CAS federation mode is enable, redirect to user CAS logout page @@ -135,8 +137,7 @@ class LogoutView(View, LogoutMixin): if auth is not None: params = utils.copy_params(request.GET) url = auth.get_logout_url() - if url: - return HttpResponseRedirect(utils.update_url(url, params)) + return HttpResponseRedirect(utils.update_url(url, params)) # if service is set, redirect to service after logout if self.service: list(messages.get_messages(request)) # clean messages before leaving the django app @@ -201,16 +202,16 @@ class FederateAuth(View): @staticmethod def get_cas_client(request, provider): """return a CAS client object matching provider""" - if provider in settings.CAS_FEDERATE_PROVIDERS: # pragma: no branch (should always be true) - service_url = utils.get_current_url(request, {"ticket", "provider"}) - return CASFederateValidateUser(provider, service_url) + service_url = utils.get_current_url(request, {"ticket", "provider"}) + return CASFederateValidateUser(provider, service_url) def post(self, request, provider=None): """method called on POST request""" if not settings.CAS_FEDERATE: return redirect("cas_server:login") # POST with a provider, this is probably an SLO request - if provider in settings.CAS_FEDERATE_PROVIDERS: + try: + provider = FederatedIendityProvider.objects.get(suffix=provider) auth = self.get_cas_client(request, provider) try: auth.clean_sessions(request.POST['logoutRequest']) @@ -218,7 +219,7 @@ class FederateAuth(View): pass return HttpResponse("ok") # else, a User is trying to log in using an identity provider - else: + except FederatedIendityProvider.DoesNotExist: # Manually checking for csrf to protect the code below reason = CsrfViewMiddleware().process_view(request, None, (), {}) if reason is not None: # pragma: no cover (csrf checks are disabled during tests) @@ -231,7 +232,7 @@ class FederateAuth(View): ) url = utils.reverse_params( "cas_server:federateAuth", - kwargs=dict(provider=form.cleaned_data["provider"]), + kwargs=dict(provider=form.cleaned_data["provider"].suffix), params=params ) response = HttpResponseRedirect(url) @@ -240,7 +241,7 @@ class FederateAuth(View): utils.set_cookie( response, "_remember_provider", - request.POST["provider"], + form.cleaned_data["provider"].suffix, max_age ) return response @@ -251,23 +252,24 @@ class FederateAuth(View): """method called on GET request""" if not settings.CAS_FEDERATE: return redirect("cas_server:login") - if provider not in settings.CAS_FEDERATE_PROVIDERS: - return redirect("cas_server:login") - auth = self.get_cas_client(request, provider) - if 'ticket' not in request.GET: - return HttpResponseRedirect(auth.get_login_url()) - else: - ticket = request.GET['ticket'] - if auth.verify_ticket(ticket): - params = utils.copy_params(request.GET, ignore={"ticket"}) - username = u"%s@%s" % (auth.username, auth.provider) - request.session["federate_username"] = username - request.session["federate_ticket"] = ticket - auth.register_slo(username, request.session.session_key, ticket) - url = utils.reverse_params("cas_server:login", params) - return HttpResponseRedirect(url) - else: + try: + provider = FederatedIendityProvider.objects.get(suffix=provider) + auth = self.get_cas_client(request, provider) + if 'ticket' not in request.GET: return HttpResponseRedirect(auth.get_login_url()) + else: + ticket = request.GET['ticket'] + if auth.verify_ticket(ticket): + params = utils.copy_params(request.GET, ignore={"ticket"}) + request.session["federate_username"] = auth.federated_username + request.session["federate_ticket"] = ticket + auth.register_slo(auth.federated_username, request.session.session_key, ticket) + url = utils.reverse_params("cas_server:login", params) + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(auth.get_login_url()) + except FederatedIendityProvider.DoesNotExist: + return redirect("cas_server:login") class LoginView(View, LogoutMixin): @@ -347,18 +349,11 @@ class LoginView(View, LogoutMixin): _(u"Invalid login ticket") ) elif ret == self.USER_LOGIN_OK: - try: - self.user = models.User.objects.get( - username=self.request.session['username'], - session_key=self.request.session.session_key - ) - self.user.save() # pragma: no cover (should not happend) - except models.User.DoesNotExist: - self.user = models.User.objects.create( - username=self.request.session['username'], - session_key=self.request.session.session_key - ) - self.user.save() + self.user = models.User.objects.get_or_create( + username=self.request.session['username'], + session_key=self.request.session.session_key + )[0] + self.user.save() elif ret == self.USER_LOGIN_FAILURE: # bad user login if settings.CAS_FEDERATE: self.ticket = None @@ -639,8 +634,9 @@ class LoginView(View, LogoutMixin): else: if ( self.request.COOKIES.get('_remember_provider') and - self.request.COOKIES['_remember_provider'] in - settings.CAS_FEDERATE_PROVIDERS + FederatedIendityProvider.objects.filter( + suffix=self.request.COOKIES['_remember_provider'] + ) ): params = utils.copy_params(self.request.GET) url = utils.reverse_params( @@ -708,16 +704,10 @@ class Auth(View): ) if form.is_valid(): try: - try: - user = models.User.objects.get( - username=form.cleaned_data['username'], - session_key=request.session.session_key - ) - except models.User.DoesNotExist: - user = models.User.objects.create( - username=form.cleaned_data['username'], - session_key=request.session.session_key - ) + user = models.User.objects.get_or_create( + username=form.cleaned_data['username'], + session_key=request.session.session_key + )[0] user.save() # is the service allowed service_pattern = ServicePattern.validate(service) @@ -789,6 +779,7 @@ class Validate(View): return HttpResponse(u"no\n", content_type="text/plain; charset=utf-8") +@python_2_unicode_compatible class ValidateError(Exception): """handle service validation error""" def __init__(self, code, msg=""): @@ -796,7 +787,7 @@ class ValidateError(Exception): self.msg = msg super(ValidateError, self).__init__(code) - def __unicode__(self): + def __str__(self): return u"%s" % self.msg def render(self, request): @@ -1039,6 +1030,7 @@ class Proxy(View): ) +@python_2_unicode_compatible class SamlValidateError(Exception): """handle saml validation error""" def __init__(self, code, msg=""): @@ -1046,7 +1038,7 @@ class SamlValidateError(Exception): self.msg = msg super(SamlValidateError, self).__init__(code) - def __unicode__(self): + def __str__(self): return u"%s" % self.msg def render(self, request): From b0a07efe414fa82ff343ffb7e7b91a5f68c9f089 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Mon, 4 Jul 2016 17:40:31 +0200 Subject: [PATCH 32/34] Add a display field to Identity Providers --- README.rst | 3 + cas_server/admin.py | 3 +- cas_server/forms.py | 2 +- cas_server/locale/en/LC_MESSAGES/django.mo | Bin 6105 -> 6105 bytes cas_server/locale/en/LC_MESSAGES/django.po | 80 ++++++++++-------- cas_server/locale/fr/LC_MESSAGES/django.mo | Bin 8347 -> 8496 bytes cas_server/locale/fr/LC_MESSAGES/django.po | 80 ++++++++++-------- .../0008_federatediendityprovider_display.py | 20 +++++ cas_server/models.py | 5 ++ 9 files changed, 119 insertions(+), 74 deletions(-) create mode 100644 cas_server/migrations/0008_federatediendityprovider_display.py diff --git a/README.rst b/README.rst index ebcaaa0..da4840d 100644 --- a/README.rst +++ b/README.rst @@ -354,6 +354,9 @@ An identity provider comes with 5 fields: * `CAS protocol version`: the version of the CAS protocol to use to contact the identity provider. The default is version 3. * `Verbose name`: the name used on the login page to display the identity provider. +* `Display`: a boolean controlling the display of the identity provider on the login page. + Beware that this do not disable the identity provider, it just hide it on the login page. + User will always be able to log in using this provider by fetching `/federate/provider_suffix`. In federation mode, ``django-cas-server`` build user's username as follow: diff --git a/cas_server/admin.py b/cas_server/admin.py index f2baf81..2ea243b 100644 --- a/cas_server/admin.py +++ b/cas_server/admin.py @@ -93,7 +93,8 @@ class ServicePatternAdmin(admin.ModelAdmin): class FederatedIendityProviderAdmin(admin.ModelAdmin): - fields = ('pos', 'suffix', 'server_url', 'cas_protocol_version', 'verbose_name') + fields = ('pos', 'suffix', 'server_url', 'cas_protocol_version', 'verbose_name', 'display') + list_display = ('verbose_name', 'suffix', 'display') admin.site.register(User, UserAdmin) diff --git a/cas_server/forms.py b/cas_server/forms.py index bf7a0e2..5284fac 100644 --- a/cas_server/forms.py +++ b/cas_server/forms.py @@ -34,7 +34,7 @@ class FederateSelect(forms.Form): allowing the user to choose a identity provider. """ provider = forms.ModelChoiceField( - queryset=models.FederatedIendityProvider.objects.all().order_by( + queryset=models.FederatedIendityProvider.objects.filter(display=True).order_by( "pos", "verbose_name", "suffix" diff --git a/cas_server/locale/en/LC_MESSAGES/django.mo b/cas_server/locale/en/LC_MESSAGES/django.mo index ac062279497e09c2d1d8f373a76b2b01c87a1880..8a9dda44316919636e9d665a5eb9f42f8da97fcf 100644 GIT binary patch delta 22 dcmcbqe^Y;h4KI_i*0~=zH6XP)jklN!09EP+fdBvi diff --git a/cas_server/locale/en/LC_MESSAGES/django.po b/cas_server/locale/en/LC_MESSAGES/django.po index 0ab0f93..6d4de34 100644 --- a/cas_server/locale/en/LC_MESSAGES/django.po +++ b/cas_server/locale/en/LC_MESSAGES/django.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: cas_server\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2016-07-04 17:15+0200\n" -"PO-Revision-Date: 2016-07-04 17:15+0200\n" +"POT-Creation-Date: 2016-07-04 17:36+0200\n" +"PO-Revision-Date: 2016-07-04 17:39+0200\n" "Last-Translator: Valentin Samir \n" "Language-Team: django \n" "Language: en\n" @@ -104,48 +104,56 @@ msgstr "" msgid "Name for this identity provider displayed on the login page" msgstr "" -#: models.py:70 models.py:312 +#: models.py:70 models.py:317 msgid "position" msgstr "position" -#: models.py:159 +#: models.py:80 +msgid "display" +msgstr "" + +#: models.py:81 +msgid "Display the provider on the login page" +msgstr "" + +#: models.py:164 msgid "User" msgstr "" -#: models.py:160 +#: models.py:165 msgid "Users" msgstr "" -#: models.py:229 +#: models.py:234 #, python-format msgid "Error during service logout %s" msgstr "Error during service logout %s" -#: models.py:307 +#: models.py:312 msgid "Service pattern" msgstr "Service pattern" -#: models.py:308 +#: models.py:313 msgid "Services patterns" msgstr "" -#: models.py:313 +#: models.py:318 msgid "service patterns are sorted using the position attribute" msgstr "" -#: models.py:320 models.py:444 +#: models.py:325 models.py:449 msgid "name" msgstr "name" -#: models.py:321 +#: models.py:326 msgid "A name for the service" msgstr "A name for the service" -#: models.py:326 models.py:473 models.py:492 +#: models.py:331 models.py:478 models.py:497 msgid "pattern" msgstr "pattern" -#: models.py:328 +#: models.py:333 msgid "" "A regular expression matching services. Will usually looks like '^https://" "some\\.server\\.com/path/.*$'.As it is a regular expression, special " @@ -155,73 +163,73 @@ msgstr "" "some\\.server\\.com/path/.*$'.As it is a regular expression, special " "character must be escaped with a '\\'." -#: models.py:337 +#: models.py:342 msgid "user field" msgstr "" -#: models.py:338 +#: models.py:343 msgid "Name of the attribut to transmit as username, empty = login" msgstr "Name of the attribut to transmit as username, empty = login" -#: models.py:342 +#: models.py:347 msgid "restrict username" msgstr "" -#: models.py:343 +#: models.py:348 msgid "Limit username allowed to connect to the list provided bellow" msgstr "Limit username allowed to connect to the list provided bellow" -#: models.py:347 +#: models.py:352 msgid "proxy" msgstr "proxy" -#: models.py:348 +#: models.py:353 msgid "Proxy tickets can be delivered to the service" msgstr "Proxy tickets can be delivered to the service" -#: models.py:352 +#: models.py:357 msgid "proxy callback" msgstr "proxy callback" -#: models.py:353 +#: models.py:358 msgid "can be used as a proxy callback to deliver PGT" msgstr "can be used as a proxy callback to deliver PGT" -#: models.py:357 +#: models.py:362 msgid "single log out" msgstr "" -#: models.py:358 +#: models.py:363 msgid "Enable SLO for the service" msgstr "Enable SLO for the service" -#: models.py:365 +#: models.py:370 msgid "single log out callback" msgstr "" -#: models.py:366 +#: models.py:371 msgid "" "URL where the SLO request will be POST. empty = service url\n" "This is usefull for non HTTP proxied services." msgstr "" -#: models.py:428 +#: models.py:433 msgid "username" msgstr "" -#: models.py:429 +#: models.py:434 msgid "username allowed to connect to the service" msgstr "username allowed to connect to the service" -#: models.py:445 +#: models.py:450 msgid "name of an attribut to send to the service, use * for all attributes" msgstr "name of an attribut to send to the service, use * for all attributes" -#: models.py:450 models.py:498 +#: models.py:455 models.py:503 msgid "replace" msgstr "replace" -#: models.py:451 +#: models.py:456 msgid "" "name under which the attribut will be showto the service. empty = default " "name of the attribut" @@ -229,27 +237,27 @@ msgstr "" "name under which the attribut will be showto the service. empty = default " "name of the attribut" -#: models.py:468 models.py:487 +#: models.py:473 models.py:492 msgid "attribut" msgstr "attribut" -#: models.py:469 +#: models.py:474 msgid "Name of the attribut which must verify pattern" msgstr "Name of the attribut which must verify pattern" -#: models.py:474 +#: models.py:479 msgid "a regular expression" msgstr "a regular expression" -#: models.py:488 +#: models.py:493 msgid "Name of the attribut for which the value must be replace" msgstr "Name of the attribut for which the value must be replace" -#: models.py:493 +#: models.py:498 msgid "An regular expression maching whats need to be replaced" msgstr "An regular expression maching whats need to be replaced" -#: models.py:499 +#: models.py:504 msgid "replace expression, groups are capture by \\1, \\2 …" msgstr "replace expression, groups are capture by \\1, \\2 …" diff --git a/cas_server/locale/fr/LC_MESSAGES/django.mo b/cas_server/locale/fr/LC_MESSAGES/django.mo index 362bc4caf94487b373335bdc130cf87f19738c44..cd54e7aeb82557f437c101613f1562769afc3463 100644 GIT binary patch delta 1705 zcmYk+eP~T_9LMpmyGNUcJ#mcTGTS`u$#92_jbRfae@uC(ac8?)yK9r8xPOpBqHg|> zP!pw?aoZBL2$4Lr;t&4thZ2${{NX8if6hJ0ulv2P^E>CB-|u(6zwf!HYkQYQUS}nq zG+G0ZM+ALlO?WwxFIri$**Pr76F7)Raa)SnSRBIZn3QT3z#F(2KYNxAH=9qt9XVwe zFb8{Zl3B#=a-f0UU@rc^EX+=epD)Bh`qeldgUF2*!Av}h8n73u@jgz*gmg33YWbLi zGrfKpE}|d6O6IpN@5E&+;lg_yiOvYKVk|&SxB-=+PSk>r<7m8yT1Y>x#)mi;$1uoF zT!l(zA8KJYu@+xp4)a^ysQ3!YQ3C`}PjnEKvQt=s-AJ}=5H;aTRHi=>O|Tbd;sGQH z)`MEmEj&KV>^An%U&pF1VsbY5zr?|n?08BWdF&zj?WiZ%GRAB@cH>g~hVI^xfAza? z9QNZ>e1=o-3(mqECtfo($j3JERfSzR5qll-uMTC1n-dg zv=3A&NwTlF922NajnjZZJcL@nM`X?BOfZ{=#i;SNV-X&SaG(`mN3vtjQG5CpJMcRy z6Co<^81^9bX;my=6VziP?m|C4My37}w%|`>4YrL6))wu-PpV z!Ea2!R7!Rgj__ zm1Hr&KayLf+F(CHwYz(wiiy^yOpcW%4Wm|!RAhXxZUly zg`O*Pa{7)Pq3x~Q+1BijMjR)4 ZK6iXk{?ssxyYbLGr{RNL@uCxFE delta 1598 zcmY+^OGs346vy%7_{`L_%t=d~%q-I~-&SfmJ(Q@V2UJLkU=4+7;ldW-twk-e7YtF5 z5F!Q9qHK_$Y$LQxY|%q{P()xYLP>$E{wWv zj0R#EvEO6XhL@taFp^`;Zet2w!y)WLf2>&=j^QKxij{aX&a4*4T^GzVt6;qwxn<`u z6|Z2HnPY=Yw9zQ0;{-0mAMX8_c(V-FnaCel6{g?;)JDg!2756ZN02kxdyK{}?)n>U zVm*yjSU6v6$Lt7`b=(-hL>$Mp_!0HMgoL@w6rc{;hzqb4b&xLHiRZ8k$M85tvYC?D zi8@#})?*NL@K+ek^X)qmZ7}VAP?I#5s=cU#HKQJM5tW&K)SEp(JzyBO;uCDa->6i# za7wCT0o1w+^>?S;`@QJs#21+80Z)cFcJW~XqNbp)rri;wXhwk(>3F`pn z;8|RbgIIviQN1#QUQFPUiMd#fe$;)3%ND%hCI1JRyyHe5ma%&TCvQgj&koa(yu1Z) z8}^_!dWP*di+XS?Pvwl(hvhhc+V2gn!e6L^<}KwLg^j3E9&(tRWYU4kzz4jHIeZuC zKYNIJzz80|N%UbIucef?VE{XkXV`00X=ZRE&Z6G9oKEJfwhOgy03*@qX0n4xkLxIE z<3w6k&60uI(C4o6QJ)v#W~@g3*%>bMo%N%d?mp@OA$L86>am}$alUYGIhMghsn~~V z#-m6D{*D@VR^+N|tR;%wH3_jgqJ*d?G}OczDxK>5?@&23^cB?aPq@L<-Kw`zs5I$~ z8weUT{FZ7uei*~u4dE1$sBq0y=L$mK1dVmX3Zj-!FKDQ=tBHJK1EJ>D@Ktl!PAJuC zuu4MNpltt*TvoJ3I79kC-JceG8SQOcuO&k}sm1;ss<4W%k|-qjfw59T1*8t)&QMXR zh+tc6cBm<4Dl*g_e\n" "Language-Team: django \n" "Language: fr\n" @@ -111,48 +111,56 @@ msgstr "Nom du fournisseur" msgid "Name for this identity provider displayed on the login page" msgstr "Nom affiché pour ce fournisseur d'identité sur la page de connexion" -#: models.py:70 models.py:312 +#: models.py:70 models.py:317 msgid "position" msgstr "position" -#: models.py:159 +#: models.py:80 +msgid "display" +msgstr "afficher" + +#: models.py:81 +msgid "Display the provider on the login page" +msgstr "Afficher le fournisseur d'identité sur la page de connexion" + +#: models.py:164 msgid "User" msgstr "Utilisateur" -#: models.py:160 +#: models.py:165 msgid "Users" msgstr "Utilisateurs" -#: models.py:229 +#: models.py:234 #, python-format msgid "Error during service logout %s" msgstr "Une erreur est survenue durant la déconnexion du service %s" -#: models.py:307 +#: models.py:312 msgid "Service pattern" msgstr "Motif de service" -#: models.py:308 +#: models.py:313 msgid "Services patterns" msgstr "Motifs de services" -#: models.py:313 +#: models.py:318 msgid "service patterns are sorted using the position attribute" msgstr "Les motifs de service sont trié selon l'attribut position" -#: models.py:320 models.py:444 +#: models.py:325 models.py:449 msgid "name" msgstr "nom" -#: models.py:321 +#: models.py:326 msgid "A name for the service" msgstr "Un nom pour le service" -#: models.py:326 models.py:473 models.py:492 +#: models.py:331 models.py:478 models.py:497 msgid "pattern" msgstr "motif" -#: models.py:328 +#: models.py:333 msgid "" "A regular expression matching services. Will usually looks like '^https://" "some\\.server\\.com/path/.*$'.As it is a regular expression, special " @@ -163,55 +171,55 @@ msgstr "" "expression rationnelle, les caractères spéciaux doivent être échappés avec " "un '\\'." -#: models.py:337 +#: models.py:342 msgid "user field" msgstr "champ utilisateur" -#: models.py:338 +#: models.py:343 msgid "Name of the attribut to transmit as username, empty = login" msgstr "" "Nom de l'attribut devant être transmis comme nom d'utilisateur au service. " "vide = nom de connection" -#: models.py:342 +#: models.py:347 msgid "restrict username" msgstr "limiter les noms d'utilisateurs" -#: models.py:343 +#: models.py:348 msgid "Limit username allowed to connect to the list provided bellow" msgstr "" "Limiter les noms d'utilisateurs autorisé à se connecter à la liste fournie " "ci-dessous" -#: models.py:347 +#: models.py:352 msgid "proxy" msgstr "proxy" -#: models.py:348 +#: models.py:353 msgid "Proxy tickets can be delivered to the service" msgstr "des proxy tickets peuvent être délivrés au service" -#: models.py:352 +#: models.py:357 msgid "proxy callback" msgstr "" -#: models.py:353 +#: models.py:358 msgid "can be used as a proxy callback to deliver PGT" msgstr "peut être utilisé comme un callback pour recevoir un PGT" -#: models.py:357 +#: models.py:362 msgid "single log out" msgstr "" -#: models.py:358 +#: models.py:363 msgid "Enable SLO for the service" msgstr "Active le SLO pour le service" -#: models.py:365 +#: models.py:370 msgid "single log out callback" msgstr "" -#: models.py:366 +#: models.py:371 msgid "" "URL where the SLO request will be POST. empty = service url\n" "This is usefull for non HTTP proxied services." @@ -220,51 +228,51 @@ msgstr "" "service\n" "Ceci n'est utilise que pour des services non HTTP proxifiés" -#: models.py:428 +#: models.py:433 msgid "username" msgstr "nom d'utilisateur" -#: models.py:429 +#: models.py:434 msgid "username allowed to connect to the service" msgstr "noms d'utilisateurs autorisé à se connecter au service" -#: models.py:445 +#: models.py:450 msgid "name of an attribut to send to the service, use * for all attributes" msgstr "" "nom d'un attribut a envoyer au service, utiliser * pour tous les attributs" -#: models.py:450 models.py:498 +#: models.py:455 models.py:503 msgid "replace" msgstr "remplacement" -#: models.py:451 +#: models.py:456 msgid "" "name under which the attribut will be showto the service. empty = default " "name of the attribut" msgstr "" "nom sous lequel l'attribut sera rendu visible au service. vide = inchangé" -#: models.py:468 models.py:487 +#: models.py:473 models.py:492 msgid "attribut" msgstr "attribut" -#: models.py:469 +#: models.py:474 msgid "Name of the attribut which must verify pattern" msgstr "Nom de l'attribut devant vérifier un motif" -#: models.py:474 +#: models.py:479 msgid "a regular expression" msgstr "une expression régulière" -#: models.py:488 +#: models.py:493 msgid "Name of the attribut for which the value must be replace" msgstr "nom de l'attribue pour lequel la valeur doit être remplacé" -#: models.py:493 +#: models.py:498 msgid "An regular expression maching whats need to be replaced" msgstr "une expression régulière reconnaissant ce qui doit être remplacé" -#: models.py:499 +#: models.py:504 msgid "replace expression, groups are capture by \\1, \\2 …" msgstr "expression de remplacement, les groupe sont capturé par \\1, \\2" diff --git a/cas_server/migrations/0008_federatediendityprovider_display.py b/cas_server/migrations/0008_federatediendityprovider_display.py new file mode 100644 index 0000000..ec1a3b2 --- /dev/null +++ b/cas_server/migrations/0008_federatediendityprovider_display.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-07-04 15:33 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cas_server', '0007_auto_20160704_1510'), + ] + + operations = [ + migrations.AddField( + model_name='federatediendityprovider', + name='display', + field=models.BooleanField(default=True, help_text='Display the provider on the login page', verbose_name='display'), + ), + ] diff --git a/cas_server/models.py b/cas_server/models.py index 971cce5..675260c 100644 --- a/cas_server/models.py +++ b/cas_server/models.py @@ -75,6 +75,11 @@ class FederatedIendityProvider(models.Model): ) ) ) + display = models.BooleanField( + default=True, + verbose_name=_(u"display"), + help_text=_("Display the provider on the login page") + ) def __str__(self): return self.verbose_name From 624f2f48eddc3578b70146c0ce0fcbb72df70df2 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Mon, 4 Jul 2016 20:02:00 +0200 Subject: [PATCH 33/34] Add a docstring to admin.py --- cas_server/admin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cas_server/admin.py b/cas_server/admin.py index 2ea243b..848b481 100644 --- a/cas_server/admin.py +++ b/cas_server/admin.py @@ -93,6 +93,7 @@ class ServicePatternAdmin(admin.ModelAdmin): class FederatedIendityProviderAdmin(admin.ModelAdmin): + """`FederatedIendityProvider` in admin interface""" fields = ('pos', 'suffix', 'server_url', 'cas_protocol_version', 'verbose_name', 'display') list_display = ('verbose_name', 'suffix', 'display') From 6b3b280d316574394cf99a783e13ec7dcba6765e Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Mon, 4 Jul 2016 22:54:15 +0200 Subject: [PATCH 34/34] Add some logging and only permit backend CAS auth if the user is not already authenticated --- cas_server/federate.py | 9 +++++++++ cas_server/tests/mixin.py | 5 +++-- cas_server/views.py | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/cas_server/federate.py b/cas_server/federate.py index 4534cda..74528cb 100644 --- a/cas_server/federate.py +++ b/cas_server/federate.py @@ -16,11 +16,14 @@ from django.db import IntegrityError from .cas import CASClient from .models import FederatedUser, FederateSLO, User +import logging from importlib import import_module from six.moves import urllib SessionStore = import_module(settings.SESSION_ENGINE).SessionStore +logger = logging.getLogger(__name__) + class CASFederateValidateUser(object): """Class CAS client used to authenticate the user again a CAS provider""" @@ -88,6 +91,12 @@ class CASFederateValidateUser(object): slos = [] for slo in slos: for federate_slo in FederateSLO.objects.filter(ticket=slo.text): + logger.info( + "Got an SLO requests for ticket %s, logging out user %s" % ( + federate_slo.username, + federate_slo.ticket + ) + ) session = SessionStore(session_key=federate_slo.session_key) session.flush() try: diff --git a/cas_server/tests/mixin.py b/cas_server/tests/mixin.py index 859c3a0..e4a5c0d 100644 --- a/cas_server/tests/mixin.py +++ b/cas_server/tests/mixin.py @@ -228,11 +228,12 @@ class CanLogin(object): self.assertEqual(response.status_code, code) # this message is displayed to the user upon successful authentication, so it should not # appear - self.assertFalse( + self.assertNotIn( ( b"You have successfully logged into " b"the Central Authentication Service" - ) in response.content + ), + response.content ) # if authentication has failed, these session variables should not be set diff --git a/cas_server/views.py b/cas_server/views.py index a95a6cd..c85cc52 100644 --- a/cas_server/views.py +++ b/cas_server/views.py @@ -208,6 +208,7 @@ class FederateAuth(View): def post(self, request, provider=None): """method called on POST request""" if not settings.CAS_FEDERATE: + logger.warning("CAS_FEDERATE is False, set it to True to use the federated mode") return redirect("cas_server:login") # POST with a provider, this is probably an SLO request try: @@ -251,15 +252,26 @@ class FederateAuth(View): def get(self, request, provider=None): """method called on GET request""" if not settings.CAS_FEDERATE: + logger.warning("CAS_FEDERATE is False, set it to True to use the federated mode") + return redirect("cas_server:login") + if self.request.session.get("authenticated"): + logger.warning("User already authenticated, dropping federate authentication request") return redirect("cas_server:login") try: provider = FederatedIendityProvider.objects.get(suffix=provider) auth = self.get_cas_client(request, provider) if 'ticket' not in request.GET: + logger.info("Trying to authenticate again %s" % auth.provider.server_url) return HttpResponseRedirect(auth.get_login_url()) else: ticket = request.GET['ticket'] if auth.verify_ticket(ticket): + logger.info( + "Got a valid ticket for %s from %s" % ( + auth.username, + auth.provider.server_url + ) + ) params = utils.copy_params(request.GET, ignore={"ticket"}) request.session["federate_username"] = auth.federated_username request.session["federate_ticket"] = ticket @@ -267,8 +279,15 @@ class FederateAuth(View): url = utils.reverse_params("cas_server:login", params) return HttpResponseRedirect(url) else: + logger.info( + "Got a invalid ticket for %s from %s. Retrying to authenticate" % ( + auth.username, + auth.provider.server_url + ) + ) return HttpResponseRedirect(auth.get_login_url()) except FederatedIendityProvider.DoesNotExist: + logger.warning("Identity provider suffix %s not found" % provider) return redirect("cas_server:login")