Commit aa433d3c authored by Valentin Samir's avatar Valentin Samir

Use django admin application to add/modif identty providers when CAS_FEDERATE is True

parent 40b4f070
......@@ -12,6 +12,7 @@ exclude_lines =
pragma: no cover
def __repr__
def __unicode__
def __str__
raise AssertionError
raise NotImplementedError
if six.PY3:
......@@ -15,3 +15,5 @@ coverage.xml
test_venv
.coverage
htmlcov/
tox_logs/
.cache/
......@@ -165,12 +165,6 @@ Federation settings
* ``CAS_FEDERATE``: A boolean for activating the federated mode (see the federate section below).
The default is ``False``.
* ``CAS_FEDERATE_PROVIDERS``: A dictionnary for the allowed identity providers (see the federate
section below). The default is ``{}``.
* ``CAS_FEDERATE_PROVIDERS_LIST``: A list in with the keys of ``CAS_FEDERATE_PROVIDERS`` are ordened
for beeing displayed on the login page. The default is the list of all the keys of
``CAS_FEDERATE_PROVIDERS`` sorted in natural order (0 < 2 < 10 < 20 < a = A < … < z = Z and
lexicographical)
* ``CAS_FEDERATE_REMEMBER_TIMEOUT``: Time after witch the cookie use for "remember my identity
provider" expire. The default is ``604800``, one week. The cookie is called
``_remember_provider``.
......@@ -344,26 +338,29 @@ to the provider CAS to authenticate. This provider transmit to ``django-cas-serv
username and attributes. The user is now logged in on ``django-cas-server`` and can use
services using ``django-cas-server`` as CAS.
The list of allowed identity providers is defined using the ``CAS_FEDERATE_PROVIDERS`` parameter.
For instance:
The list of allowed identity providers is defined using the django admin application.
With the development server started, visit http://127.0.0.1:8000/admin/ to add identity providers.
.. code-block:: python
CAS_FEDERATE_PROVIDERS = {
"example.com": ("https://cas.example.com", 3, "Example dot com"),
"exemple.fr": ("https://cas.exemple.fr", 3, "Exemple point fr"),
}
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.
``CAS_FEDERATE_PROVIDERS`` is a dictionnary using provider names as key and a tuple
(cas address, cas version protocol, provider verbose name) as value.
In federation mode, ``django-cas-server`` build user's username as follow:
``provider_returned_username@provider_name``.
You can choose the provider returned username for ``django-cas-server`` and the provider name
in order to make sense.
The "provider verbose name" is showed on the select menu of the login page.
``provider_returned_username@provider_suffix``.
Choose the provider returned username for ``django-cas-server`` and the provider suffix
in order to make sense, as this built username is likely to be displayed to end users in
applications.
Then using federate mode, you should add one command to a daily crontab: ``cas_clean_federate``.
......
......@@ -12,6 +12,7 @@
from django.contrib import admin
from .models import ServiceTicket, ProxyTicket, ProxyGrantingTicket, User, ServicePattern
from .models import Username, ReplaceAttributName, ReplaceAttributValue, FilterAttributValue
from .models import FederatedIendityProvider
from .forms import TicketForm
TICKETS_READONLY_FIELDS = ('validate', 'service', 'service_pattern',
......@@ -91,5 +92,10 @@ class ServicePatternAdmin(admin.ModelAdmin):
'single_log_out', 'proxy_callback', 'restrict_users')
class FederatedIendityProviderAdmin(admin.ModelAdmin):
fields = ('pos', 'suffix', 'server_url', 'cas_protocol_version', 'verbose_name')
admin.site.register(User, UserAdmin)
admin.site.register(ServicePattern, ServicePatternAdmin)
admin.site.register(FederatedIendityProvider, FederatedIendityProviderAdmin)
......@@ -148,16 +148,13 @@ class CASFederateAuth(AuthUser):
user = None
def __init__(self, username):
component = username.split('@')
username = '@'.join(component[:-1])
provider = component[-1]
try:
self.user = FederatedUser.objects.get(username=username, provider=provider)
self.user = FederatedUser.get_from_federated_username(username)
super(CASFederateAuth, self).__init__(
"%s@%s" % (self.user.username, self.user.provider)
self.user.federated_username
)
except FederatedUser.DoesNotExist:
super(CASFederateAuth, self).__init__("%s@%s" % (username, provider))
super(CASFederateAuth, self).__init__(username)
def test_password(self, ticket):
"""test `password` agains the user"""
......
......@@ -13,8 +13,6 @@
from django.conf import settings
from django.contrib.staticfiles.templatetags.staticfiles import static
import re
def setting_default(name, default_value):
"""if the config `name` is not set, set it the `default_value`"""
......@@ -92,30 +90,7 @@ setting_default(
setting_default('CAS_ENABLE_AJAX_AUTH', False)
setting_default('CAS_FEDERATE', False)
# A dict of "provider suffix" -> (provider CAS server url, CAS version, verbose name)
setting_default('CAS_FEDERATE_PROVIDERS', {})
setting_default('CAS_FEDERATE_REMEMBER_TIMEOUT', 604800) # one week
if settings.CAS_FEDERATE:
settings.CAS_AUTH_CLASS = "cas_server.auth.CASFederateAuth"
# create CAS_FEDERATE_PROVIDERS_LIST default value if not set: list of
# the keys of CAS_FEDERATE_PROVIDERS in natural order: 2 < 10 < 20 < a = A < … < z = Z
try:
getattr(settings, 'CAS_FEDERATE_PROVIDERS_LIST')
except AttributeError:
__CAS_FEDERATE_PROVIDERS_LIST = list(settings.CAS_FEDERATE_PROVIDERS.keys())
def __cas_federate_providers_list_sort(key):
if len(settings.CAS_FEDERATE_PROVIDERS[key]) > 2:
key = settings.CAS_FEDERATE_PROVIDERS[key][2].lower()
else:
key = key.lower()
return tuple(
int(num) if num else alpha
for num, alpha in __cas_federate_providers_list_sort.tokenize(key)
)
__cas_federate_providers_list_sort.tokenize = re.compile(r'(\d+)|(\D+)').findall
__CAS_FEDERATE_PROVIDERS_LIST.sort(key=__cas_federate_providers_list_sort)
setting_default('CAS_FEDERATE_PROVIDERS_LIST', __CAS_FEDERATE_PROVIDERS_LIST)
......@@ -11,6 +11,7 @@
# (c) 2016 Valentin Samir
"""federated mode helper classes"""
from .default_settings import settings
from django.db import IntegrityError
from .cas import CASClient
from .models import FederatedUser, FederateSLO, User
......@@ -29,28 +30,23 @@ class CASFederateValidateUser(object):
def __init__(self, provider, service_url):
self.provider = provider
if provider in settings.CAS_FEDERATE_PROVIDERS: # pragma: no branch (should always be True)
(server_url, version) = settings.CAS_FEDERATE_PROVIDERS[provider][:2]
self.client = CASClient(
service_url=service_url,
version=version,
server_url=server_url,
renew=False,
)
self.client = CASClient(
service_url=service_url,
version=provider.cas_protocol_version,
server_url=provider.server_url,
renew=False,
)
def get_login_url(self):
"""return the CAS provider login url"""
return self.client.get_login_url() if self.client is not None else False
return self.client.get_login_url()
def get_logout_url(self, redirect_url=None):
"""return the CAS provider logout url"""
return self.client.get_logout_url(redirect_url) if self.client is not None else False
return self.client.get_logout_url(redirect_url)
def verify_ticket(self, ticket):
"""test `ticket` agains the CAS provider, if valid, create the local federated user"""
if self.client is None: # pragma: no cover (should not happen)
return False
try:
username, attributs = self.client.verify_ticket(ticket)[:2]
except urllib.error.URLError:
......@@ -61,22 +57,13 @@ class CASFederateValidateUser(object):
attributs["provider"] = self.provider
self.username = username
self.attributs = attributs
try:
user = FederatedUser.objects.get(
username=username,
provider=self.provider
)
user.attributs = attributs
user.ticket = ticket
user.save()
except FederatedUser.DoesNotExist:
user = FederatedUser.objects.create(
username=username,
provider=self.provider,
attributs=attributs,
ticket=ticket
)
user.save()
user = FederatedUser.objects.update_or_create(
username=username,
provider=self.provider,
defaults=dict(attributs=attributs, ticket=ticket)
)[0]
user.save()
self.federated_username = user.federated_username
return True
else:
return False
......@@ -84,11 +71,14 @@ class CASFederateValidateUser(object):
@staticmethod
def register_slo(username, session_key, ticket):
"""association a ticket with a (username, session) for processing later SLO request"""
FederateSLO.objects.create(
username=username,
session_key=session_key,
ticket=ticket
)
try:
FederateSLO.objects.create(
username=username,
session_key=session_key,
ticket=ticket
)
except IntegrityError: # pragma: no cover (ignore if the FederateSLO already exists)
pass
def clean_sessions(self, logout_request):
"""process a SLO request"""
......
......@@ -33,16 +33,14 @@ class FederateSelect(forms.Form):
Form used on the login page when CAS_FEDERATE is True
allowing the user to choose a identity provider.
"""
provider = forms.ChoiceField(
provider = forms.ModelChoiceField(
queryset=models.FederatedIendityProvider.objects.all().order_by(
"pos",
"verbose_name",
"suffix"
),
to_field_name="suffix",
label=_('Identity provider'),
# with use a lambda abstraction to delay the access to settings.CAS_FEDERATE_PROVIDERS
# this is usefull to use the override_settings decorator in tests
choices=[
(
p,
utils.get_tuple(settings.CAS_FEDERATE_PROVIDERS[p], 2, p)
) for p in settings.CAS_FEDERATE_PROVIDERS_LIST
]
)
service = forms.CharField(label=_('service'), widget=forms.HiddenInput(), required=False)
method = forms.CharField(widget=forms.HiddenInput(), required=False)
......@@ -88,13 +86,10 @@ class FederateUserCredential(UserCredential):
def clean(self):
cleaned_data = super(FederateUserCredential, self).clean()
try:
component = cleaned_data["username"].split('@')
username = '@'.join(component[:-1])
provider = component[-1]
user = models.FederatedUser.objects.get(username=username, provider=provider)
user = models.FederatedUser.get_from_federated_username(cleaned_data["username"])
user.ticket = ""
user.save()
# should not happed as is the FederatedUser do not exists, super should
# should not happed as if the FederatedUser do not exists, super should
# raise before a ValidationError("bad user")
except models.FederatedUser.DoesNotExist: # pragma: no cover (should not happend)
raise forms.ValidationError(
......
......@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: cas_server\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2016-06-21 00:14+0200\n"
"PO-Revision-Date: 2016-06-21 00:16+0200\n"
"POT-Creation-Date: 2016-07-04 17:15+0200\n"
"PO-Revision-Date: 2016-07-04 17:15+0200\n"
"Last-Translator: Valentin Samir <valentin.samir@crans.org>\n"
"Language-Team: django <LL@li.org>\n"
"Language: en\n"
......@@ -17,88 +17,135 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 1.8.8\n"
#: apps.py:7 templates/cas_server/base.html:3 templates/cas_server/base.html:21
#: apps.py:19 templates/cas_server/base.html:3
#: templates/cas_server/base.html:20
msgid "Central Authentication Service"
msgstr "Central Authentication Service"
#: forms.py:32
#: forms.py:43
msgid "Identity provider"
msgstr "Identity provider"
#: forms.py:35 forms.py:44 forms.py:92
#: forms.py:45 forms.py:55 forms.py:106
msgid "service"
msgstr ""
#: forms.py:37
#: forms.py:47
msgid "Remember the identity provider"
msgstr "Remember the identity provider"
#: forms.py:38 forms.py:48
#: forms.py:48 forms.py:59
msgid "warn"
msgstr " Warn me before logging me into other sites."
#: forms.py:43
#: forms.py:54
msgid "login"
msgstr "username"
#: forms.py:45
#: forms.py:56
msgid "password"
msgstr "password"
#: forms.py:59
#: forms.py:71
msgid "Bad user"
msgstr "The credentials you provided cannot be determined to be authentic."
#: management/commands/cas_clean_federate.py:13
#: forms.py:96
msgid "User not found in the temporary database, please try to reconnect"
msgstr ""
#: management/commands/cas_clean_federate.py:20
msgid "Clean old federated users"
msgstr "Clean old federated users"
#: management/commands/cas_clean_sessions.py:9
#: management/commands/cas_clean_sessions.py:22
msgid "Clean deleted sessions"
msgstr "Clean deleted sessions"
#: management/commands/cas_clean_tickets.py:9
#: management/commands/cas_clean_tickets.py:22
msgid "Clean old trickets"
msgstr "Clean old trickets"
#: models.py:55
#: models.py:42
msgid "identity provider"
msgstr "identity provider"
#: models.py:43
msgid "identity providers"
msgstr "identity providers"
#: models.py:47
msgid "suffix"
msgstr ""
#: models.py:48
msgid ""
"Suffix append to backend CAS returner username: `returned_username`@`suffix`"
msgstr ""
#: models.py:50
msgid "server url"
msgstr ""
#: models.py:59
msgid "CAS protocol version"
msgstr ""
#: models.py:60
msgid ""
"Version of the CAS protocol to use when sending requests the the backend CAS"
msgstr ""
#: models.py:65
msgid "verbose name"
msgstr ""
#: models.py:66
msgid "Name for this identity provider displayed on the login page"
msgstr ""
#: models.py:70 models.py:312
msgid "position"
msgstr "position"
#: models.py:159
msgid "User"
msgstr ""
#: models.py:56
#: models.py:160
msgid "Users"
msgstr ""
#: models.py:114
#: models.py:229
#, python-format
msgid "Error during service logout %s"
msgstr "Error during service logout %s"
#: models.py:182
#: models.py:307
msgid "Service pattern"
msgstr "Service pattern"
#: models.py:183
#: models.py:308
msgid "Services patterns"
msgstr ""
#: models.py:187
msgid "position"
msgstr "position"
#: models.py:313
msgid "service patterns are sorted using the position attribute"
msgstr ""
#: models.py:194 models.py:316
#: models.py:320 models.py:444
msgid "name"
msgstr "name"
#: models.py:195
#: models.py:321
msgid "A name for the service"
msgstr "A name for the service"
#: models.py:200 models.py:344 models.py:362
#: models.py:326 models.py:473 models.py:492
msgid "pattern"
msgstr "pattern"
#: models.py:202
#: models.py:328
msgid ""
"A regular expression matching services. Will usually looks like '^https://"
"some\\.server\\.com/path/.*$'.As it is a regular expression, special "
......@@ -108,73 +155,73 @@ msgstr ""
"some\\.server\\.com/path/.*$'.As it is a regular expression, special "
"character must be escaped with a '\\'."
#: models.py:211
#: models.py:337
msgid "user field"
msgstr ""
#: models.py:212
#: models.py:338
msgid "Name of the attribut to transmit as username, empty = login"
msgstr "Name of the attribut to transmit as username, empty = login"
#: models.py:216
#: models.py:342
msgid "restrict username"
msgstr ""
#: models.py:217
#: models.py:343
msgid "Limit username allowed to connect to the list provided bellow"
msgstr "Limit username allowed to connect to the list provided bellow"
#: models.py:221
#: models.py:347
msgid "proxy"
msgstr "proxy"
#: models.py:222
#: models.py:348
msgid "Proxy tickets can be delivered to the service"
msgstr "Proxy tickets can be delivered to the service"
#: models.py:226
#: models.py:352
msgid "proxy callback"
msgstr "proxy callback"
#: models.py:227
#: models.py:353
msgid "can be used as a proxy callback to deliver PGT"
msgstr "can be used as a proxy callback to deliver PGT"
#: models.py:231
#: models.py:357
msgid "single log out"
msgstr ""
#: models.py:232
#: models.py:358
msgid "Enable SLO for the service"
msgstr "Enable SLO for the service"
#: models.py:239
#: models.py:365
msgid "single log out callback"
msgstr ""
#: models.py:240
#: models.py:366
msgid ""
"URL where the SLO request will be POST. empty = service url\n"
"This is usefull for non HTTP proxied services."
msgstr ""
#: models.py:301
#: models.py:428
msgid "username"
msgstr ""
#: models.py:302
#: models.py:429
msgid "username allowed to connect to the service"
msgstr "username allowed to connect to the service"
#: models.py:317
#: models.py:445
msgid "name of an attribut to send to the service, use * for all attributes"
msgstr "name of an attribut to send to the service, use * for all attributes"
#: models.py:322 models.py:368
#: models.py:450 models.py:498
msgid "replace"
msgstr "replace"
#: models.py:323
#: models.py:451
msgid ""
"name under which the attribut will be showto the service. empty = default "
"name of the attribut"
......@@ -182,39 +229,30 @@ msgstr ""
"name under which the attribut will be showto the service. empty = default "
"name of the attribut"
#: models.py:339 models.py:357
#: models.py:468 models.py:487
msgid "attribut"
msgstr "attribut"
#: models.py:340
#: models.py:469
msgid "Name of the attribut which must verify pattern"
msgstr "Name of the attribut which must verify pattern"
#: models.py:345
#: models.py:474
msgid "a regular expression"
msgstr "a regular expression"
#: models.py:358
#: models.py:488
msgid "Name of the attribut for which the value must be replace"
msgstr "Name of the attribut for which the value must be replace"
#: models.py:363
#: models.py:493
msgid "An regular expression maching whats need to be replaced"
msgstr "An regular expression maching whats need to be replaced"
#: models.py:369
#: models.py:499
msgid "replace expression, groups are capture by \\1, \\2 …"
msgstr "replace expression, groups are capture by \\1, \\2 …"
#: models.py:476
#, python-format
msgid ""
"Error during service logout %(service)s:\n"
"%(error)s"
msgstr ""
"Error during service logout %(service)s:\n"
"%(error)s"
#: templates/cas_server/logged.html:6
msgid "Logged"
msgstr ""
......@@ -243,7 +281,7 @@ msgstr "Login"
msgid "Connect to the service"
msgstr "Connect to the service"
#: views.py:140
#: views.py:152
msgid ""
"<h3>Logout successful</h3>You have successfully logged out from the Central "
"Authentication Service. For security reasons, exit your web browser."
......@@ -251,7 +289,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:146
#: views.py:158
#, python-format
msgid ""
"<h3>Logout successful</h3>You have successfully logged out from %s sessions "
......@@ -262,7 +300,7 @@ msgstr ""
"of the Central Authentication Service. For security reasons, exit your web "
"browser."
#: views.py:153
#: views.py:165
msgid ""
"<h3>Logout successful</h3>You were already logged out from the Central "
"Authentication Service. For security reasons, exit your web browser."
......@@ -270,48 +308,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:294
#: views.py:349
msgid "Invalid login ticket"
msgstr "Invalid login ticket, please retry to login"
#: views.py:410
#: views.py:470
#, python-format
msgid "Authentication has been required by service %(name)s (%(url)s)"
msgstr "Authentication has been required by service %(name)s (%(url)s)"
#: views.py:448
#: views.py:508
#, python-format
msgid "Service %(url)s non allowed."
msgstr "Service %(url)s non allowed."
#: views.py:455
#: views.py:515
msgid "Username non allowed"
msgstr "Username non allowed"
#: views.py:462
#: views.py:522
msgid "User charateristics non allowed"
msgstr "User charateristics non allowed"
#: views.py:469
#: views.py:529
#, python-format
msgid "The attribut %(field)s is needed to use that service"
msgstr "The attribut %(field)s is needed to use that service"
#: views.py:539
#: views.py:599
#, python-format
msgid "Authentication renewal required by service %(name)s (%(url)s)."
msgstr "Authentication renewal required by service %(name)s (%(url)s)."
#: views.py:546
#: views.py:606
#, python-format
msgid "Authentication required by service %(name)s (%(url)s)."
msgstr "Authentication required by service %(name)s (%(url)s)."
#: views.py:553
#: views.py:613
#, python-format
msgid "Service %s non allowed"
msgstr "Service %s non allowed"
#~ msgid ""
#~ "Error during service logout %(service)s:\n"
#~ "%(error)s"
#~ msgstr ""
#~ "Error during service logout %(service)s:\n"
#~ "%(error)s"
#~ msgid "Successfully logout"
#~ msgstr ""
#~ "<h3>Logout successful</h3>You have successfully logged out of the Central "
......
This diff is collapsed.
# -*- 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')]),
),
]
......@@ -17,6 +17,7 @@ from django.db.models import Q
from django.contrib import messages
from django.utils.translation import ugettext_lazy as _
from django.utils import timezone
from django.utils.encoding import python_2_unicode_compatible
from picklefield.fields import PickledObjectField
import re
......@@ -34,18 +35,93 @@ SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
logger = logging.getLogger(__name__)