diff --git a/.coveragerc b/.coveragerc
index 8f6e752..771fe83 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -5,12 +5,14 @@ omit =
cas_server/migrations*
cas_server/management/*
cas_server/tests/*
+ cas_server/cas.py
[report]
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 549d387..da4840d 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
@@ -158,6 +159,17 @@ 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_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
-------------------------
@@ -245,6 +257,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
====
@@ -313,3 +327,51 @@ 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 use
+services using ``django-cas-server`` as CAS.
+
+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.
+
+An identity provider comes with 5 fields:
+
+* `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.
+* `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:
+``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``.
+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/admin.py b/cas_server/admin.py
index 472e1df..848b481 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,12 @@ class ServicePatternAdmin(admin.ModelAdmin):
'single_log_out', 'proxy_callback', 'restrict_users')
+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')
+
+
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 2826a85..9f40ae4 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
@@ -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: # pragma: no cover
import MySQLdb
import MySQLdb.cursors
@@ -19,6 +22,8 @@ try: # pragma: no cover
except ImportError:
MySQLdb = None
+from .models import FederatedUser
+
class AuthUser(object):
"""Authentication base class"""
@@ -136,3 +141,35 @@ class DjangoAuthUser(AuthUser): # pragma: no cover
return attr
else:
return {}
+
+
+class CASFederateAuth(AuthUser):
+ """Authentication class used then CAS_FEDERATE is True"""
+ user = None
+
+ def __init__(self, username):
+ try:
+ self.user = FederatedUser.get_from_federated_username(username)
+ super(CASFederateAuth, self).__init__(
+ self.user.federated_username
+ )
+ except FederatedUser.DoesNotExist:
+ super(CASFederateAuth, self).__init__(username)
+
+ 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: # pragma: no cover (should not happen)
+ return {}
+ else:
+ return self.user.attributs
diff --git a/cas_server/cas.py b/cas_server/cas.py
new file mode 100644
index 0000000..9eec396
--- /dev/null
+++ b/cas_server/cas.py
@@ -0,0 +1,394 @@
+# 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
+# some modifications have been made to be unicode coherent between python2 and python2
+
+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
+from uuid import uuid4
+import datetime
+
+
+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):
+ """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, ReturnUnicode):
+ """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)]
+ url = (urllib_parse.urljoin(self.server_url, 'validate') + '?' +
+ urllib_parse.urlencode(params))
+ page = urllib_request.urlopen(url)
+ try:
+ verified = page.readline().strip()
+ 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, ReturnUnicode):
+ """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, 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)]
+ 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:
+ 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, charset):
+ attributes = dict()
+ for attribute in element:
+ tag = cls.self.unicode(attribute.tag, charset).split(u"}").pop()
+ if tag in attributes:
+ if isinstance(attributes[tag], list):
+ attributes[tag].append(cls.unicode(attribute.text, charset))
+ else:
+ attributes[tag] = [attributes[tag]]
+ attributes[tag].append(cls.unicode(attribute.text, charset))
+ else:
+ if tag == u'attraStyle':
+ pass
+ else:
+ attributes[tag] = cls.unicode(attribute.text, charset)
+ return attributes
+
+ @classmethod
+ 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, charset):
+ 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 = cls.unicode(element.text, charset)
+ elif element.tag.endswith('proxyGrantingTicket'):
+ pgtiou = cls.unicode(element.text, charset)
+ elif element.tag.endswith('attributes'):
+ attributes = cls.parse_attributes_xml_element(element, charset)
+ 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, charset):
+ attributes = dict()
+ for attribute in element:
+ tag = cls.unicode(attribute.tag, charset).split(u"}").pop()
+ if tag in attributes:
+ if isinstance(attributes[tag], list):
+ attributes[tag].append(cls.unicode(attribute.text, charset))
+ else:
+ attributes[tag] = [attributes[tag]]
+ attributes[tag].append(cls.unicode(attribute.text, charset))
+ else:
+ attributes[tag] = cls.unicode(attribute.text, charset)
+ return attributes
+
+ @classmethod
+ def verify_response(cls, response, charset):
+ return cls.parse_response_xml(response, charset)
+
+
+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)
+ content_type = page.info().get('Content-type')
+ if "charset=" in content_type:
+ charset = content_type.split("charset=")[-1]
+ else:
+ charset = "ascii"
+
+ 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
+ name_identifier = tree.find('.//' + SAML_1_0_ASSERTION_NS + 'NameIdentifier')
+ if name_identifier is not None:
+ 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 = 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(self.unicode(v.text, charset))
+ attributes[key] = values_array
+ else:
+ attributes[key] = self.unicode(values[0].text, charset)
+ 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 1d2174c..0b24f62 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
@@ -7,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
@@ -87,3 +88,9 @@ setting_default(
)
setting_default('CAS_ENABLE_AJAX_AUTH', False)
+
+setting_default('CAS_FEDERATE', False)
+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/federate.py b/cas_server/federate.py
new file mode 100644
index 0000000..74528cb
--- /dev/null
+++ b/cas_server/federate.py
@@ -0,0 +1,111 @@
+# -*- 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
+"""federated mode helper classes"""
+from .default_settings import settings
+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"""
+ username = None
+ attributs = {}
+ client = None
+
+ def __init__(self, provider, service_url):
+ self.provider = provider
+ 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()
+
+ def get_logout_url(self, redirect_url=None):
+ """return the CAS provider logout url"""
+ 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"""
+ try:
+ username, attributs = self.client.verify_ticket(ticket)[:2]
+ except urllib.error.URLError:
+ return False
+ if username is not None:
+ if attributs is None:
+ attributs = {}
+ attributs["provider"] = self.provider
+ self.username = username
+ self.attributs = attributs
+ 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
+
+ @staticmethod
+ def register_slo(username, session_key, ticket):
+ """association a ticket with a (username, session) for processing later SLO request"""
+ 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"""
+ try:
+ slos = self.client.get_saml_slos(logout_request) or []
+ except NameError: # pragma: no cover (should not happen)
+ 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:
+ 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 83cfe8a..5284fac 100644
--- a/cas_server/forms.py
+++ b/cas_server/forms.py
@@ -28,6 +28,27 @@ class WarnForm(forms.Form):
lt = forms.CharField(widget=forms.HiddenInput(), required=False)
+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.ModelChoiceField(
+ queryset=models.FederatedIendityProvider.objects.filter(display=True).order_by(
+ "pos",
+ "verbose_name",
+ "suffix"
+ ),
+ to_field_name="suffix",
+ label=_('Identity provider'),
+ )
+ 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)
+ renew = forms.BooleanField(widget=forms.HiddenInput(), required=False)
+
+
class UserCredential(forms.Form):
"""Form used on the login page to retrive user credentials"""
username = forms.CharField(label=_('login'))
@@ -51,6 +72,32 @@ class UserCredential(forms.Form):
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)
+ renew = forms.BooleanField(widget=forms.HiddenInput(), required=False)
+
+ def clean(self):
+ cleaned_data = super(FederateUserCredential, self).clean()
+ try:
+ user = models.FederatedUser.get_from_federated_username(cleaned_data["username"])
+ user.ticket = ""
+ user.save()
+ # 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(
+ _(u"User not found in the temporary database, please try to reconnect")
+ )
+ return cleaned_data
+
+
class TicketForm(forms.ModelForm):
"""Form for Tickets in the admin interface"""
class Meta:
diff --git a/cas_server/locale/en/LC_MESSAGES/django.mo b/cas_server/locale/en/LC_MESSAGES/django.mo
index 686826e..8a9dda4 100644
Binary files a/cas_server/locale/en/LC_MESSAGES/django.mo and b/cas_server/locale/en/LC_MESSAGES/django.mo differ
diff --git a/cas_server/locale/en/LC_MESSAGES/django.po b/cas_server/locale/en/LC_MESSAGES/django.po
index e513335..6d4de34 100644
--- a/cas_server/locale/en/LC_MESSAGES/django.po
+++ b/cas_server/locale/en/LC_MESSAGES/django.po
@@ -7,86 +7,153 @@ 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-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"
"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:19 templates/cas_server/base.html:3
+#: templates/cas_server/base.html:20
msgid "Central Authentication Service"
msgstr "Central Authentication Service"
-#: forms.py:23
-msgid "login"
-msgstr "username"
+#: forms.py:43
+msgid "Identity provider"
+msgstr "Identity provider"
-#: forms.py:24 forms.py:47
+#: forms.py:45 forms.py:55 forms.py:106
msgid "service"
msgstr ""
-#: forms.py:25
-msgid "password"
-msgstr "password"
+#: forms.py:47
+msgid "Remember the identity provider"
+msgstr "Remember the identity provider"
-#: forms.py:28
+#: forms.py:48 forms.py:59
msgid "warn"
msgstr " Warn me before logging me into other sites."
-#: forms.py:39
+#: forms.py:54
+msgid "login"
+msgstr "username"
+
+#: forms.py:56
+msgid "password"
+msgstr "password"
+
+#: forms.py:71
msgid "Bad user"
msgstr "The credentials you provided cannot be determined to be authentic."
-#: management/commands/cas_clean_sessions.py:9
+#: 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: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: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:317
+msgid "position"
+msgstr "position"
+
+#: 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:43
+#: models.py:165
msgid "Users"
msgstr ""
-#: models.py:101
+#: models.py:234
#, python-format
msgid "Error during service logout %s"
msgstr "Error during service logout %s"
-#: models.py:169
+#: models.py:312
msgid "Service pattern"
msgstr "Service pattern"
-#: models.py:170
+#: models.py:313
msgid "Services patterns"
msgstr ""
-#: models.py:174
-msgid "position"
-msgstr "position"
+#: models.py:318
+msgid "service patterns are sorted using the position attribute"
+msgstr ""
-#: models.py:181 models.py:303
+#: models.py:325 models.py:449
msgid "name"
msgstr "name"
-#: models.py:182
+#: models.py:326
msgid "A name for the service"
msgstr "A name for the service"
-#: models.py:187 models.py:331 models.py:349
+#: models.py:331 models.py:478 models.py:497
msgid "pattern"
msgstr "pattern"
-#: models.py:189
+#: 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 "
@@ -96,73 +163,73 @@ msgstr ""
"some\\.server\\.com/path/.*$'.As it is a regular expression, special "
"character must be escaped with a '\\'."
-#: models.py:198
+#: models.py:342
msgid "user field"
msgstr ""
-#: models.py:199
+#: 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:203
+#: models.py:347
msgid "restrict username"
msgstr ""
-#: models.py:204
+#: 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:208
+#: models.py:352
msgid "proxy"
msgstr "proxy"
-#: models.py:209
+#: models.py:353
msgid "Proxy tickets can be delivered to the service"
msgstr "Proxy tickets can be delivered to the service"
-#: models.py:213
+#: models.py:357
msgid "proxy callback"
msgstr "proxy callback"
-#: models.py:214
+#: 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:218
+#: models.py:362
msgid "single log out"
msgstr ""
-#: models.py:219
+#: models.py:363
msgid "Enable SLO for the service"
msgstr "Enable SLO for the service"
-#: models.py:226
+#: models.py:370
msgid "single log out callback"
msgstr ""
-#: models.py:227
+#: 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:288
+#: models.py:433
msgid "username"
msgstr ""
-#: models.py:289
+#: models.py:434
msgid "username allowed to connect to the service"
msgstr "username allowed to connect to the service"
-#: models.py:304
+#: 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:309 models.py:355
+#: models.py:455 models.py:503
msgid "replace"
msgstr "replace"
-#: models.py:310
+#: models.py:456
msgid ""
"name under which the attribut will be showto the service. empty = default "
"name of the attribut"
@@ -170,39 +237,30 @@ 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:473 models.py:492
msgid "attribut"
msgstr "attribut"
-#: models.py:327
+#: models.py:474
msgid "Name of the attribut which must verify pattern"
msgstr "Name of the attribut which must verify pattern"
-#: models.py:332
+#: models.py:479
msgid "a regular expression"
msgstr "a regular expression"
-#: models.py:345
+#: 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:350
+#: 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:356
+#: models.py:504
msgid "replace expression, groups are capture by \\1, \\2 …"
msgstr "replace expression, groups are capture by \\1, \\2 …"
-#: models.py:463
-#, 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 ""
@@ -219,19 +277,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:152
msgid ""
"
Logout successful
You have successfully logged out from the Central "
"Authentication Service. For security reasons, exit your web browser."
@@ -239,7 +297,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:158
#, python-format
msgid ""
"
Logout successful
You have successfully logged out from %s sessions "
@@ -250,7 +308,7 @@ msgstr ""
"of the Central Authentication Service. For security reasons, exit your web "
"browser."
-#: views.py:141
+#: views.py:165
msgid ""
"
Logout successful
You were already logged out from the Central "
"Authentication Service. For security reasons, exit your web browser."
@@ -258,48 +316,55 @@ 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:349
msgid "Invalid login ticket"
msgstr "Invalid login ticket, please retry to login"
-#: views.py:325
+#: 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:359
+#: views.py:508
#, python-format
msgid "Service %(url)s non allowed."
msgstr "Service %(url)s non allowed."
-#: views.py:366
+#: views.py:515
msgid "Username non allowed"
msgstr "Username non allowed"
-#: views.py:373
+#: views.py:522
msgid "User charateristics non allowed"
msgstr "User charateristics non allowed"
-#: views.py:380
+#: 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:450
+#: 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:457
+#: 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:464
+#: 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 c8d14ba..cd54e7a 100644
Binary files a/cas_server/locale/fr/LC_MESSAGES/django.mo and b/cas_server/locale/fr/LC_MESSAGES/django.mo differ
diff --git a/cas_server/locale/fr/LC_MESSAGES/django.po b/cas_server/locale/fr/LC_MESSAGES/django.po
index fd50dbd..8a7e606 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-07-04 17:36+0200\n"
+"PO-Revision-Date: 2016-07-04 17:37+0200\n"
"Last-Translator: Valentin Samir \n"
"Language-Team: django \n"
"Language: fr\n"
@@ -16,78 +16,151 @@ 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:19 templates/cas_server/base.html:3
+#: templates/cas_server/base.html:20
msgid "Central Authentication Service"
msgstr "Service Central d'Authentification"
-#: forms.py:23
-msgid "login"
-msgstr "Identifiant"
+#: forms.py:43
+msgid "Identity provider"
+msgstr "fournisseur d'identité"
-#: forms.py:24 forms.py:47
+#: forms.py:45 forms.py:55 forms.py:106
msgid "service"
msgstr "service"
-#: forms.py:25
-msgid "password"
-msgstr "mot de passe"
+#: forms.py:47
+msgid "Remember the identity provider"
+msgstr "Se souvenir du fournisseur d'identité"
-#: forms.py:28
+#: forms.py:48 forms.py:59
msgid "warn"
msgstr "Prévenez-moi avant d'accéder à d'autres services."
-#: forms.py:39
+#: forms.py:54
+msgid "login"
+msgstr "Identifiant"
+
+#: forms.py:56
+msgid "password"
+msgstr "mot de passe"
+
+#: forms.py:71
msgid "Bad user"
msgstr "Les informations transmises n'ont pas permis de vous authentifier."
-#: management/commands/cas_clean_sessions.py:9
+#: 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: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: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:317
+msgid "position"
+msgstr "position"
+
+#: 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:43
+#: models.py:165
msgid "Users"
msgstr "Utilisateurs"
-#: models.py:101
+#: 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:169
+#: models.py:312
msgid "Service pattern"
msgstr "Motif de service"
-#: models.py:170
+#: models.py:313
msgid "Services patterns"
msgstr "Motifs de services"
-#: models.py:174
-msgid "position"
-msgstr "position"
+#: 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:181 models.py:303
+#: models.py:325 models.py:449
msgid "name"
msgstr "nom"
-#: models.py:182
+#: models.py:326
msgid "A name for the service"
msgstr "Un nom pour le service"
-#: models.py:187 models.py:331 models.py:349
+#: models.py:331 models.py:478 models.py:497
msgid "pattern"
msgstr "motif"
-#: models.py:189
+#: 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 "
@@ -98,55 +171,55 @@ msgstr ""
"expression rationnelle, les caractères spéciaux doivent être échappés avec "
"un '\\'."
-#: models.py:198
+#: models.py:342
msgid "user field"
msgstr "champ utilisateur"
-#: models.py:199
+#: 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:203
+#: models.py:347
msgid "restrict username"
msgstr "limiter les noms d'utilisateurs"
-#: models.py:204
+#: 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:208
+#: models.py:352
msgid "proxy"
msgstr "proxy"
-#: models.py:209
+#: 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:213
+#: models.py:357
msgid "proxy callback"
msgstr ""
-#: models.py:214
+#: 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:218
+#: models.py:362
msgid "single log out"
msgstr ""
-#: models.py:219
+#: models.py:363
msgid "Enable SLO for the service"
msgstr "Active le SLO pour le service"
-#: models.py:226
+#: models.py:370
msgid "single log out callback"
msgstr ""
-#: models.py:227
+#: models.py:371
msgid ""
"URL where the SLO request will be POST. empty = service url\n"
"This is usefull for non HTTP proxied services."
@@ -155,63 +228,54 @@ msgstr ""
"service\n"
"Ceci n'est utilise que pour des services non HTTP proxifiés"
-#: models.py:288
+#: models.py:433
msgid "username"
msgstr "nom d'utilisateur"
-#: models.py:289
+#: models.py:434
msgid "username allowed to connect to the service"
msgstr "noms d'utilisateurs autorisé à se connecter au service"
-#: models.py:304
+#: 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:309 models.py:355
+#: models.py:455 models.py:503
msgid "replace"
msgstr "remplacement"
-#: models.py:310
+#: 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:326 models.py:344
+#: models.py:473 models.py:492
msgid "attribut"
msgstr "attribut"
-#: models.py:327
+#: models.py:474
msgid "Name of the attribut which must verify pattern"
msgstr "Nom de l'attribut devant vérifier un motif"
-#: models.py:332
+#: models.py:479
msgid "a regular expression"
msgstr "une expression régulière"
-#: models.py:345
+#: 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:350
+#: 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:356
+#: models.py:504
msgid "replace expression, groups are capture by \\1, \\2 …"
msgstr "expression de remplacement, les groupe sont capturé par \\1, \\2"
-#: models.py:463
-#, 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 ""
@@ -228,19 +292,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:152
msgid ""
"
Logout successful
You have successfully logged out from the Central "
"Authentication Service. For security reasons, exit your web browser."
@@ -249,7 +313,7 @@ msgstr ""
"d'Authentification. Pour des raisons de sécurité, veuillez fermer votre "
"navigateur."
-#: views.py:134
+#: views.py:158
#, python-format
msgid ""
"
Logout successful
You have successfully logged out from %s sessions "
@@ -260,7 +324,7 @@ msgstr ""
"Service Central d'Authentification. Pour des raisons de sécurité, veuillez "
"fermer votre navigateur."
-#: views.py:141
+#: views.py:165
msgid ""
"
Logout successful
You were already logged out from the Central "
"Authentication Service. For security reasons, exit your web browser."
@@ -269,50 +333,57 @@ msgstr ""
"d'Authentification. Pour des raisons de sécurité, veuillez fermer votre "
"navigateur."
-#: views.py:230
+#: views.py:349
msgid "Invalid login ticket"
msgstr "Ticket de connexion invalide, merci de réessayé de vous connecter"
-#: views.py:325
+#: 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:359
+#: views.py:508
#, python-format
msgid "Service %(url)s non allowed."
msgstr "le service %(url)s n'est pas autorisé."
-#: views.py:366
+#: views.py:515
msgid "Username non allowed"
msgstr "Nom d'utilisateur non authorisé"
-#: views.py:373
+#: views.py:522
msgid "User charateristics non allowed"
msgstr "Caractéristique utilisateur non autorisée"
-#: views.py:380
+#: 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:450
+#: 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:457
+#: 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:464
+#: 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/management/commands/cas_clean_federate.py b/cas_server/management/commands/cas_clean_federate.py
new file mode 100644
index 0000000..795230a
--- /dev/null
+++ b/cas_server/management/commands/cas_clean_federate.py
@@ -0,0 +1,24 @@
+# -*- 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 _
+
+from ... import models
+
+
+class Command(BaseCommand):
+ args = ''
+ help = _(u"Clean old federated users")
+
+ def handle(self, *args, **options):
+ models.FederatedUser.clean_old_entries()
+ models.FederateSLO.clean_deleted_sessions()
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/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/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/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/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 d870a50..675260c 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
@@ -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,6 +35,128 @@ 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"
+ )
+ )
+ )
+ 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
+
+ @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.ForeignKey(FederatedIendityProvider, on_delete=models.CASCADE)
+ attributs = PickledObjectField()
+ ticket = models.CharField(max_length=255)
+ last_update = models.DateTimeField(auto_now=True)
+
+ 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):
+ """remove old unused federated users"""
+ 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 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", "ticket")
+ username = models.CharField(max_length=30)
+ session_key = models.CharField(max_length=40, blank=True, null=True)
+ ticket = models.CharField(max_length=255, db_index=True)
+
+ @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()
+
+
+@python_2_unicode_compatible
class User(models.Model):
"""A user logged into the CAS"""
class Meta:
@@ -44,6 +167,15 @@ class User(models.Model):
username = models.CharField(max_length=30)
date = models.DateTimeField(auto_now=True)
+ def delete(self, *args, **kwargs):
+ """remove the User"""
+ 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"""
@@ -67,7 +199,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):
@@ -172,6 +304,7 @@ class UserFieldNotDefined(ServicePatternException):
pass
+@python_2_unicode_compatible
class ServicePattern(models.Model):
"""Allowed services pattern agains services are tested to"""
class Meta:
@@ -181,7 +314,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,
@@ -238,7 +372,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):
@@ -291,6 +425,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(
@@ -300,10 +435,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:
@@ -322,13 +458,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(
@@ -343,10 +480,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(
@@ -367,10 +505,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:
@@ -387,7 +526,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
@@ -457,34 +596,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:
@@ -492,5 +635,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/templates/cas_server/base.html b/cas_server/templates/cas_server/base.html
index 574cec6..bebf439 100644
--- a/cas_server/templates/cas_server/base.html
+++ b/cas_server/templates/cas_server/base.html
@@ -12,6 +12,7 @@
{% block bootstrap3_content %}