Merge pull request #6 from nitmir/federate
Add a federated mode. When the settings CAS_FEDERATE is True, django-cas-server will offer to the user to choose its CAS backend to authenticate. Hence the login page do not display anymore a username/password form but a select form with configured CASs backend. This allow to give access to CAS supported applications to users from multiple organization seamlessly. It was originally developped to mach the need of https://myares.fr (Federated CAS at https://cas.myares.fr, example of an application using it as https://chat.myares.fr)
This commit is contained in:
commit
37bb766245
@ -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:
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -15,3 +15,5 @@ coverage.xml
|
||||
test_venv
|
||||
.coverage
|
||||
htmlcov/
|
||||
tox_logs/
|
||||
.cache/
|
||||
|
62
README.rst
62
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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
394
cas_server/cas.py
Normal file
394
cas_server/cas.py
Normal file
@ -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 = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<SOAP-ENV:Header/>
|
||||
<SOAP-ENV:Body>
|
||||
<samlp:Request xmlns:samlp="urn:oasis:names:tc:SAML:1.0:protocol"
|
||||
MajorVersion="1"
|
||||
MinorVersion="1"
|
||||
RequestID="{request_id}"
|
||||
IssueInstant="{timestamp}">
|
||||
<samlp:AssertionArtifact>{ticket}</samlp:AssertionArtifact></samlp:Request>
|
||||
</SOAP-ENV:Body>
|
||||
</SOAP-ENV:Envelope>"""
|
||||
|
||||
|
||||
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 <carlewis@gmail.com>
|
||||
|
||||
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')
|
@ -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"
|
||||
|
111
cas_server/federate.py
Normal file
111
cas_server/federate.py
Normal file
@ -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()
|
@ -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:
|
||||
|
Binary file not shown.
@ -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 <valentin.samir@crans.org>\n"
|
||||
"Language-Team: django <LL@li.org>\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 ""
|
||||
"<h3>Logout successful</h3>You have successfully logged out from the Central "
|
||||
"Authentication Service. For security reasons, exit your web browser."
|
||||
@ -239,7 +297,7 @@ msgstr ""
|
||||
"<h3>Logout successful</h3>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 ""
|
||||
"<h3>Logout successful</h3>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 ""
|
||||
"<h3>Logout successful</h3>You were already logged out from the Central "
|
||||
"Authentication Service. For security reasons, exit your web browser."
|
||||
@ -258,48 +316,55 @@ msgstr ""
|
||||
"<h3>Logout successful</h3>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 ""
|
||||
#~ "<h3>Logout successful</h3>You have successfully logged out of the Central "
|
||||
|
Binary file not shown.
@ -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 <valentin.samir@crans.org>\n"
|
||||
"Language-Team: django <LL@li.org>\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 ""
|
||||
"<h3>Logout successful</h3>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 ""
|
||||
"<h3>Logout successful</h3>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 ""
|
||||
"<h3>Logout successful</h3>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 ""
|
||||
#~ "<h3>Déconnexion réussie</h3>\n"
|
||||
|
24
cas_server/management/commands/cas_clean_federate.py
Normal file
24
cas_server/management/commands/cas_clean_federate.py
Normal file
@ -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()
|
@ -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 _
|
||||
|
@ -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 _
|
||||
|
31
cas_server/migrations/0005_auto_20160616_1018.py
Normal file
31
cas_server/migrations/0005_auto_20160616_1018.py
Normal file
@ -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')]),
|
||||
),
|
||||
]
|
28
cas_server/migrations/0006_auto_20160623_1516.py
Normal file
28
cas_server/migrations/0006_auto_20160623_1516.py
Normal file
@ -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')]),
|
||||
),
|
||||
]
|
50
cas_server/migrations/0007_auto_20160704_1510.py
Normal file
50
cas_server/migrations/0007_auto_20160704_1510.py
Normal file
@ -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')]),
|
||||
),
|
||||
]
|
@ -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'),
|
||||
),
|
||||
]
|
@ -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
|
||||
|
@ -12,6 +12,7 @@
|
||||
|
||||
{% block bootstrap3_content %}
|
||||
<div class="container">
|
||||
{% if auto_submit %}<noscript>{% endif %}
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
|
||||
<h1 id="app-name">
|
||||
@ -19,10 +20,13 @@
|
||||
{% trans "Central Authentication Service" %}</h1>
|
||||
</div>
|
||||
</div>
|
||||
{% if auto_submit %}</noscript>{% endif %}
|
||||
<div class="row">
|
||||
<div class="col-lg-3 col-md-3 col-sm-2 col-xs-12"></div>
|
||||
<div class="col-lg-6 col-md-6 col-sm-8 col-xs-12">
|
||||
{% if auto_submit %}<noscript>{% endif %}
|
||||
{% bootstrap_messages %}
|
||||
{% if auto_submit %}</noscript>{% endif %}
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
@ -3,11 +3,20 @@
|
||||
{% load staticfiles %}
|
||||
{% load i18n %}
|
||||
{% block content %}
|
||||
<form class="form-signin" method="post">
|
||||
<form class="form-signin" method="post" id="login_form"{% if post_url %} action="{{post_url}}"{% endif %}>
|
||||
{% if auto_submit %}<noscript>{% endif %}
|
||||
<h2 class="form-signin-heading">{% trans "Please log in" %}</h2>
|
||||
{% if auto_submit %}</noscript>{% endif %}
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form %}
|
||||
{% if auto_submit %}<noscript>{% endif %}
|
||||
{% bootstrap_button _('Login') size='lg' button_type="submit" button_class="btn-primary btn-block"%}
|
||||
{% if auto_submit %}</noscript>{% endif %}
|
||||
</form>
|
||||
{% if auto_submit %}
|
||||
<script type="text/javascript">
|
||||
document.getElementById('login_form').submit(); // SUBMIT FORM
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
@ -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
|
||||
@ -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",
|
||||
@ -191,3 +192,65 @@ 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.assertNotIn(
|
||||
(
|
||||
b"You have successfully logged into "
|
||||
b"the Central Authentication Service"
|
||||
),
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
|
360
cas_server/tests/test_federate.py
Normal file
360
cas_server/tests/test_federate.py
Normal file
@ -0,0 +1,360 @@
|
||||
# -*- 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, 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 fot test'),
|
||||
}
|
||||
|
||||
|
||||
@override_settings(
|
||||
CAS_FEDERATE=True,
|
||||
CAS_AUTH_CLASS="cas_server.auth.CASFederateAuth",
|
||||
# test with a non ascii username
|
||||
CAS_TEST_USER=u"dédé"
|
||||
)
|
||||
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()
|
||||
self.setup_federated_identity_provider(PROVIDERS)
|
||||
|
||||
def test_default_settings(self):
|
||||
"""default settings should populated some default variable then CAS_FEDERATE is True"""
|
||||
del settings.CAS_AUTH_CLASS
|
||||
reload_module(default_settings)
|
||||
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 provider in models.FederatedIendityProvider.objects.all():
|
||||
self.assertTrue('<option value="%s">%s</option>' % (
|
||||
provider.suffix,
|
||||
provider.verbose_name
|
||||
) 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 (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
|
||||
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.suffix
|
||||
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.suffix
|
||||
))
|
||||
else:
|
||||
self.assertEqual(response["Location"], '%s/federate/%s' % (
|
||||
'http://testserver' if django.VERSION < (1, 9) else "",
|
||||
provider.suffix
|
||||
))
|
||||
# let's follow the redirect
|
||||
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" % (
|
||||
provider.server_url,
|
||||
provider.suffix
|
||||
)
|
||||
)
|
||||
# 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.suffix).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.suffix, {'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=provider.build_username(settings.CAS_TEST_USER)
|
||||
)
|
||||
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)
|
||||
"""
|
||||
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" % (
|
||||
models.FederatedIendityProvider.objects.get(suffix=good_provider).server_url,
|
||||
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" % (
|
||||
models.FederatedIendityProvider.objects.get(suffix=good_provider).server_url,
|
||||
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.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.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.suffix,
|
||||
{'logoutRequest': "<root></root>"}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.content, b"ok")
|
||||
response = client.get("/login")
|
||||
self.assert_logged(
|
||||
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.suffix,
|
||||
{'logoutRequest': tests_utils.logout_request(ticket)}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.content, b"ok")
|
||||
|
||||
response = client.get("/login")
|
||||
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=provider.build_username(settings.CAS_TEST_USER)
|
||||
)
|
||||
|
||||
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, then follow normal logout
|
||||
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" % provider.server_url,
|
||||
)
|
||||
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
|
||||
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.suffix
|
||||
))
|
||||
|
||||
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"] = models.FederatedIendityProvider.build_username_from_suffix(
|
||||
settings.CAS_TEST_USER,
|
||||
provider
|
||||
)
|
||||
session["federate_ticket"] = utils.gen_st()
|
||||
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
|
||||
# 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 provider in models.FederatedIendityProvider.objects.all():
|
||||
self.assertIn(
|
||||
'<option value="%s">%s</option>' % (
|
||||
provider.suffix,
|
||||
provider.verbose_name
|
||||
),
|
||||
response.content.decode("utf-8")
|
||||
)
|
||||
self.assertEqual(response.context['post_url'], '/federate')
|
@ -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,20 +12,87 @@
|
||||
"""Tests module for models"""
|
||||
from cas_server.default_settings import settings
|
||||
|
||||
from django.test import TestCase
|
||||
import django
|
||||
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
|
||||
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, 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=provider, attributs={}, ticket=""
|
||||
)
|
||||
models.FederatedUser.objects.create(
|
||||
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=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):
|
||||
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
|
||||
"""
|
||||
if django.VERSION >= (1, 8):
|
||||
client1 = Client()
|
||||
client2 = Client()
|
||||
client1.get("/login")
|
||||
client2.get("/login")
|
||||
session = client2.session
|
||||
session['authenticated'] = True
|
||||
session.save()
|
||||
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"""
|
||||
|
@ -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
|
||||
@ -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)
|
||||
|
@ -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
|
||||
@ -471,7 +431,7 @@ class LoginTestCase(TestCase, BaseServicePattern):
|
||||
data = json.loads(response.content.decode("utf8"))
|
||||
self.assertEqual(data["status"], "error")
|
||||
self.assertEqual(data["detail"], "login required")
|
||||
self.assertEqual(data["url"], "/login?")
|
||||
self.assertEqual(data["url"], "/login")
|
||||
|
||||
@override_settings(CAS_ENABLE_AJAX_AUTH=True)
|
||||
def test_ajax_logged_user_deleted(self):
|
||||
@ -491,7 +451,7 @@ class LoginTestCase(TestCase, BaseServicePattern):
|
||||
data = json.loads(response.content.decode("utf8"))
|
||||
self.assertEqual(data["status"], "error")
|
||||
self.assertEqual(data["detail"], "login required")
|
||||
self.assertEqual(data["url"], "/login?")
|
||||
self.assertEqual(data["url"], "/login")
|
||||
|
||||
@override_settings(CAS_ENABLE_AJAX_AUTH=True)
|
||||
def test_ajax_logged(self):
|
||||
|
@ -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
|
||||
@ -13,14 +13,38 @@
|
||||
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):
|
||||
"""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:
|
||||
return string
|
||||
|
||||
|
||||
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:
|
||||
return string
|
||||
|
||||
|
||||
def copy_form(form):
|
||||
@ -149,10 +173,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 +202,139 @@ class Http404Handler(HttpParamsHandler):
|
||||
def do_POST(self):
|
||||
"""Called on a POST request on the BaseHTTPServer"""
|
||||
return self.do_GET()
|
||||
|
||||
|
||||
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
|
||||
self.params.get("ticket").encode("ascii") == self.server.ticket
|
||||
):
|
||||
self.server.ticket = None
|
||||
return True
|
||||
else:
|
||||
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":
|
||||
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():
|
||||
template = loader.get_template('cas_server/serviceValidate.xml')
|
||||
context = Context({
|
||||
'username': self.server.username,
|
||||
'attributes': self.server.attributes
|
||||
})
|
||||
self.wfile.write(return_bytes(template.render(context), "utf8"))
|
||||
else:
|
||||
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(template.render(context), "utf8"))
|
||||
else:
|
||||
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":
|
||||
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
|
||||
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,
|
||||
'ResponseID': utils.gen_saml_id(),
|
||||
'username': self.server.username,
|
||||
'attributes': self.server.attributes,
|
||||
})
|
||||
self.wfile.write(return_bytes(template.render(context), "utf8"))
|
||||
else:
|
||||
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(template.render(context), "utf8"))
|
||||
else:
|
||||
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")
|
||||
|
||||
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):
|
||||
"""build a SLO request XML, ready to be send"""
|
||||
return u"""<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
|
||||
ID="%(id)s" Version="2.0" IssueInstant="%(datetime)s">
|
||||
<saml:NameID xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"></saml:NameID>
|
||||
<samlp:SessionIndex>%(ticket)s</samlp:SessionIndex>
|
||||
</samlp:LogoutRequest>""" % \
|
||||
{
|
||||
'id': utils.gen_saml_id(),
|
||||
'datetime': timezone.now().isoformat(),
|
||||
'ticket': ticket
|
||||
}
|
||||
|
@ -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
|
||||
@ -59,4 +59,5 @@ urlpatterns = patterns(
|
||||
),
|
||||
name='auth'
|
||||
),
|
||||
url("^federate(?:/(?P<provider>([^/]+)))?$", views.FederateAuth.as_view(), name='federateAuth'),
|
||||
)
|
||||
|
@ -25,6 +25,7 @@ import base64
|
||||
import six
|
||||
|
||||
from importlib import import_module
|
||||
from datetime import datetime, timedelta
|
||||
from six.moves.urllib.parse import urlparse, urlunparse, parse_qsl, urlencode
|
||||
|
||||
|
||||
@ -68,7 +69,50 @@ def reverse_params(url_name, params=None, **kwargs):
|
||||
"""compule the reverse url or `url_name` and add GET parameters from `params` to it"""
|
||||
url = reverse(url_name, **kwargs)
|
||||
params = urlencode(params if params else {})
|
||||
if params:
|
||||
return url + "?%s" % params
|
||||
else:
|
||||
return url
|
||||
|
||||
|
||||
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 = {}
|
||||
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):
|
||||
"""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"
|
||||
)
|
||||
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=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"
|
||||
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):
|
||||
@ -152,6 +196,19 @@ def gen_saml_id():
|
||||
return _gen_ticket('_')
|
||||
|
||||
|
||||
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:
|
||||
return nuplet[index]
|
||||
except IndexError:
|
||||
return default
|
||||
|
||||
|
||||
def crypt_salt_is_valid(salt):
|
||||
"""Return True is salt is valid has a crypt salt, False otherwise"""
|
||||
if len(salt) < 2:
|
||||
|
@ -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
|
||||
@ -20,8 +20,9 @@ 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
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
|
||||
import re
|
||||
import logging
|
||||
@ -37,7 +38,8 @@ 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
|
||||
|
||||
@ -78,6 +80,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()
|
||||
@ -115,7 +122,22 @@ 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:
|
||||
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
|
||||
if settings.CAS_FEDERATE:
|
||||
if auth is not None:
|
||||
params = utils.copy_params(request.GET)
|
||||
url = auth.get_logout_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
|
||||
@ -170,6 +192,105 @@ 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"""
|
||||
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:
|
||||
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:
|
||||
provider = FederatedIendityProvider.objects.get(suffix=provider)
|
||||
auth = self.get_cas_client(request, provider)
|
||||
try:
|
||||
auth.clean_sessions(request.POST['logoutRequest'])
|
||||
except (KeyError, AttributeError):
|
||||
pass
|
||||
return HttpResponse("ok")
|
||||
# else, a User is trying to log in using an identity provider
|
||||
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)
|
||||
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"].suffix),
|
||||
params=params
|
||||
)
|
||||
response = HttpResponseRedirect(url)
|
||||
if form.cleaned_data["remember"]:
|
||||
max_age = settings.CAS_FEDERATE_REMEMBER_TIMEOUT
|
||||
utils.set_cookie(
|
||||
response,
|
||||
"_remember_provider",
|
||||
form.cleaned_data["provider"].suffix,
|
||||
max_age
|
||||
)
|
||||
return response
|
||||
else:
|
||||
return redirect("cas_server:login")
|
||||
|
||||
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
|
||||
auth.register_slo(auth.federated_username, request.session.session_key, ticket)
|
||||
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")
|
||||
|
||||
|
||||
class LoginView(View, LogoutMixin):
|
||||
"""credential requestor / acceptor"""
|
||||
|
||||
@ -189,6 +310,10 @@ class LoginView(View, LogoutMixin):
|
||||
renewed = False
|
||||
warned = False
|
||||
|
||||
# used if CAS_FEDERATE is True
|
||||
username = None
|
||||
ticket = None
|
||||
|
||||
INVALID_LOGIN_TICKET = 1
|
||||
USER_LOGIN_OK = 2
|
||||
USER_LOGIN_FAILURE = 3
|
||||
@ -207,6 +332,9 @@ class LoginView(View, LogoutMixin):
|
||||
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 gen_lt(self):
|
||||
"""Generate a new LoginTicket and add it to the list of valid LT for the user"""
|
||||
@ -240,19 +368,16 @@ class LoginView(View, LogoutMixin):
|
||||
_(u"Invalid login ticket")
|
||||
)
|
||||
elif ret == self.USER_LOGIN_OK:
|
||||
try:
|
||||
self.user = models.User.objects.get(
|
||||
self.user = models.User.objects.get_or_create(
|
||||
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
|
||||
)
|
||||
)[0]
|
||||
self.user.save()
|
||||
elif ret == self.USER_LOGIN_FAILURE: # bad user login
|
||||
if settings.CAS_FEDERATE:
|
||||
self.ticket = None
|
||||
self.username = None
|
||||
self.init_form()
|
||||
self.logout()
|
||||
elif ret == self.USER_ALREADY_LOGGED:
|
||||
pass
|
||||
@ -300,6 +425,13 @@ class LoginView(View, LogoutMixin):
|
||||
self.method = request.GET.get('method')
|
||||
self.ajax = settings.CAS_ENABLE_AJAX_AUTH and 'HTTP_X_AJAX' in request.META
|
||||
self.warn = request.GET.get('warn')
|
||||
if settings.CAS_FEDERATE:
|
||||
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"""
|
||||
@ -318,15 +450,28 @@ class LoginView(View, LogoutMixin):
|
||||
|
||||
def init_form(self, values=None):
|
||||
"""Initialization of the good form depending of POST and GET parameters"""
|
||||
self.form = forms.UserCredential(
|
||||
values,
|
||||
initial={
|
||||
form_initial = {
|
||||
'service': self.service,
|
||||
'method': self.method,
|
||||
'warn': self.request.session.get("warn"),
|
||||
'warn': self.warn or self.request.session.get("warn"),
|
||||
'lt': self.request.session['lt'][-1],
|
||||
'renew': self.renew
|
||||
}
|
||||
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):
|
||||
@ -493,6 +638,41 @@ class LoginView(View, LogoutMixin):
|
||||
"url": utils.reverse_params("cas_server:login", params=self.request.GET)
|
||||
}
|
||||
return json_response(self.request, data)
|
||||
else:
|
||||
if settings.CAS_FEDERATE:
|
||||
if self.username and self.ticket:
|
||||
return render(
|
||||
self.request,
|
||||
settings.CAS_LOGIN_TEMPLATE,
|
||||
utils.context({
|
||||
'form': self.form,
|
||||
'auto_submit': True,
|
||||
'post_url': reverse("cas_server:login")
|
||||
})
|
||||
)
|
||||
else:
|
||||
if (
|
||||
self.request.COOKIES.get('_remember_provider') and
|
||||
FederatedIendityProvider.objects.filter(
|
||||
suffix=self.request.COOKIES['_remember_provider']
|
||||
)
|
||||
):
|
||||
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_LOGIN_TEMPLATE,
|
||||
utils.context({
|
||||
'form': self.form,
|
||||
'post_url': reverse("cas_server:federateAuth")
|
||||
})
|
||||
)
|
||||
else:
|
||||
return render(
|
||||
self.request,
|
||||
@ -525,11 +705,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={
|
||||
@ -540,16 +723,10 @@ class Auth(View):
|
||||
)
|
||||
if form.is_valid():
|
||||
try:
|
||||
try:
|
||||
user = models.User.objects.get(
|
||||
user = models.User.objects.get_or_create(
|
||||
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
|
||||
)
|
||||
)[0]
|
||||
user.save()
|
||||
# is the service allowed
|
||||
service_pattern = ServicePattern.validate(service)
|
||||
@ -557,11 +734,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):
|
||||
@ -601,7 +778,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(
|
||||
(
|
||||
@ -612,12 +792,13 @@ 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")
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class ValidateError(Exception):
|
||||
"""handle service validation error"""
|
||||
def __init__(self, code, msg=""):
|
||||
@ -625,7 +806,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):
|
||||
@ -658,8 +839,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:
|
||||
@ -729,14 +910,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"""
|
||||
@ -782,18 +963,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'
|
||||
)
|
||||
|
||||
|
||||
@ -814,8 +995,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))
|
||||
@ -828,8 +1009,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(
|
||||
@ -858,16 +1039,17 @@ 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)
|
||||
)
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class SamlValidateError(Exception):
|
||||
"""handle saml validation error"""
|
||||
def __init__(self, code, msg=""):
|
||||
@ -875,7 +1057,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):
|
||||
@ -972,18 +1154,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)
|
||||
|
Loading…
Reference in New Issue
Block a user