Unverified Commit 4229f871 authored by Valentin Samir's avatar Valentin Samir Committed by GitHub

Merge pull request #34 from nitmir/dev

 Update version to 0.9.0

v0.9.0 - 2017-11-17
===================

Added
-----
* Dutch translation
* Protuguese translation (brazilian variant)
* Support for ldap3 version 2 or more (changes in the API)
  All exception are now in ldap3.core.exceptions, methodes for fetching attritutes and
  dn are renamed.
* Possibility to disable service message boxes on the login pages

Fixed
-----
* Then using the LDAP auth backend with ``bind`` method for password check, do not try to bind
  if the user dn was not found. This was causing the exception
  ``'NoneType' object has no attribute 'getitem'`` describe in #21
* Increase the max size of usernames (30 chars to 250)
* Fix XSS js injection
parents 85eae7d6 5811d643
Pipeline #619 failed with stage
...@@ -6,6 +6,29 @@ All notable changes to this project will be documented in this file. ...@@ -6,6 +6,29 @@ All notable changes to this project will be documented in this file.
.. contents:: Table of Contents .. contents:: Table of Contents
:depth: 2 :depth: 2
v0.9.0 - 2017-11-17
===================
Added
-----
* Dutch translation
* Protuguese translation (brazilian variant)
* Support for ldap3 version 2 or more (changes in the API)
All exception are now in ldap3.core.exceptions, methodes for fetching attritutes and
dn are renamed.
* Possibility to disable service message boxes on the login pages
Fixed
-----
* Then using the LDAP auth backend with ``bind`` method for password check, do not try to bind
if the user dn was not found. This was causing the exception
``'NoneType' object has no attribute 'getitem'`` describe in #21
* Increase the max size of usernames (30 chars to 250)
* Fix XSS js injection
v0.8.0 - 2017-03-08 v0.8.0 - 2017-03-08
=================== ===================
......
...@@ -218,7 +218,8 @@ Template settings ...@@ -218,7 +218,8 @@ Template settings
} }
if you omit some keys of the dictionnary, the default value for these keys is used. if you omit some keys of the dictionnary, the default value for these keys is used.
* ``CAS_SHOW_SERVICE_MESSAGES``: Messages displayed about the state of the service on the login page.
The default is ``True``.
* ``CAS_INFO_MESSAGES``: Messages displayed in info-boxes on the html pages of the default templates. * ``CAS_INFO_MESSAGES``: Messages displayed in info-boxes on the html pages of the default templates.
It is a dictionnary mapping message name to a message dict. A message dict has 3 keys: It is a dictionnary mapping message name to a message dict. A message dict has 3 keys:
......
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
"""A django CAS server application""" """A django CAS server application"""
#: version of the application #: version of the application
VERSION = '0.8.0' VERSION = '0.9.0'
#: path the the application configuration class #: path the the application configuration class
default_app_config = 'cas_server.apps.CasAppConfig' default_app_config = 'cas_server.apps.CasAppConfig'
...@@ -27,6 +27,7 @@ except ImportError: ...@@ -27,6 +27,7 @@ except ImportError:
try: # pragma: no cover try: # pragma: no cover
import ldap3 import ldap3
import ldap3.core.exceptions
except ImportError: except ImportError:
ldap3 = None ldap3 = None
...@@ -297,9 +298,19 @@ class LdapAuthUser(DBAuthUser): # pragma: no cover ...@@ -297,9 +298,19 @@ class LdapAuthUser(DBAuthUser): # pragma: no cover
settings.CAS_LDAP_USER_QUERY % ldap3.utils.conv.escape_bytes(username), settings.CAS_LDAP_USER_QUERY % ldap3.utils.conv.escape_bytes(username),
attributes=ldap3.ALL_ATTRIBUTES attributes=ldap3.ALL_ATTRIBUTES
) and len(conn.entries) == 1: ) and len(conn.entries) == 1:
user = conn.entries[0].entry_get_attributes_dict() # try the new ldap3>=2 API
# store the user dn try:
user["dn"] = conn.entries[0].entry_get_dn() user = conn.entries[0].entry_attributes_as_dict
# store the user dn
user["dn"] = conn.entries[0].entry_dn
# fallback to ldap3<2 API
except (
ldap3.core.exceptions.LDAPKeyError, # ldap3<1 exception
ldap3.core.exceptions.LDAPAttributeError # ldap3<2 exception
):
user = conn.entries[0].entry_get_attributes_dict()
# store the user dn
user["dn"] = conn.entries[0].entry_get_dn()
if user.get(settings.CAS_LDAP_USERNAME_ATTR): if user.get(settings.CAS_LDAP_USERNAME_ATTR):
self.user = user self.user = user
super(LdapAuthUser, self).__init__(user[settings.CAS_LDAP_USERNAME_ATTR][0]) super(LdapAuthUser, self).__init__(user[settings.CAS_LDAP_USERNAME_ATTR][0])
...@@ -308,7 +319,7 @@ class LdapAuthUser(DBAuthUser): # pragma: no cover ...@@ -308,7 +319,7 @@ class LdapAuthUser(DBAuthUser): # pragma: no cover
else: else:
super(LdapAuthUser, self).__init__(username) super(LdapAuthUser, self).__init__(username)
break break
except ldap3.LDAPCommunicationError: except ldap3.core.exceptions.LDAPCommunicationError:
if retry_nb == 2: if retry_nb == 2:
raise raise
...@@ -321,7 +332,7 @@ class LdapAuthUser(DBAuthUser): # pragma: no cover ...@@ -321,7 +332,7 @@ class LdapAuthUser(DBAuthUser): # pragma: no cover
correct, ``False`` otherwise. correct, ``False`` otherwise.
:rtype: bool :rtype: bool
""" """
if settings.CAS_LDAP_PASSWORD_CHECK == "bind": if self.user and settings.CAS_LDAP_PASSWORD_CHECK == "bind":
try: try:
conn = ldap3.Connection( conn = ldap3.Connection(
settings.CAS_LDAP_SERVER, settings.CAS_LDAP_SERVER,
...@@ -336,8 +347,18 @@ class LdapAuthUser(DBAuthUser): # pragma: no cover ...@@ -336,8 +347,18 @@ class LdapAuthUser(DBAuthUser): # pragma: no cover
settings.CAS_LDAP_USER_QUERY % ldap3.utils.conv.escape_bytes(self.username), settings.CAS_LDAP_USER_QUERY % ldap3.utils.conv.escape_bytes(self.username),
attributes=ldap3.ALL_ATTRIBUTES attributes=ldap3.ALL_ATTRIBUTES
) and len(conn.entries) == 1: ) and len(conn.entries) == 1:
attributes = conn.entries[0].entry_get_attributes_dict() # try the ldap3>=2 API
attributes["dn"] = conn.entries[0].entry_get_dn() try:
attributes = conn.entries[0].entry_attributes_as_dict
# store the user dn
attributes["dn"] = conn.entries[0].entry_dn
# fallback to ldap<2 API
except (
ldap3.core.exceptions.LDAPKeyError, # ldap3<1 exception
ldap3.core.exceptions.LDAPAttributeError # ldap3<2 exception
):
attributes = conn.entries[0].entry_get_attributes_dict()
attributes["dn"] = conn.entries[0].entry_get_dn()
# cache the attributes locally as we wont have access to the user password # cache the attributes locally as we wont have access to the user password
# later. # later.
user = UserAttributes.objects.get_or_create(username=self.username)[0] user = UserAttributes.objects.get_or_create(username=self.username)[0]
...@@ -346,7 +367,10 @@ class LdapAuthUser(DBAuthUser): # pragma: no cover ...@@ -346,7 +367,10 @@ class LdapAuthUser(DBAuthUser): # pragma: no cover
finally: finally:
conn.unbind() conn.unbind()
return True return True
except (ldap3.LDAPBindError, ldap3.LDAPCommunicationError): except (
ldap3.core.exceptions.LDAPBindError,
ldap3.core.exceptions.LDAPCommunicationError
):
return False return False
elif self.user and self.user.get(settings.CAS_LDAP_PASSWORD_ATTR): elif self.user and self.user.get(settings.CAS_LDAP_PASSWORD_ATTR):
return check_password( return check_password(
......
...@@ -185,6 +185,8 @@ CAS_NEW_VERSION_EMAIL_WARNING = True ...@@ -185,6 +185,8 @@ CAS_NEW_VERSION_EMAIL_WARNING = True
#: You should not change it. #: You should not change it.
CAS_NEW_VERSION_JSON_URL = "https://pypi.python.org/pypi/django-cas-server/json" CAS_NEW_VERSION_JSON_URL = "https://pypi.python.org/pypi/django-cas-server/json"
#: If the service message should be displayed on the login page
CAS_SHOW_SERVICE_MESSAGES = True
#: Messages displayed in a info-box on the html pages of the default templates. #: Messages displayed in a info-box on the html pages of the default templates.
#: ``CAS_INFO_MESSAGES`` is a :class:`dict` mapping message name to a message :class:`dict`. #: ``CAS_INFO_MESSAGES`` is a :class:`dict` mapping message name to a message :class:`dict`.
......
This diff is collapsed.
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2017-03-28 14:10
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cas_server', '0011_auto_20161007_1258'),
]
operations = [
migrations.AlterField(
model_name='federatediendityprovider',
name='cas_protocol_version',
field=models.CharField(choices=[('1', 'CAS 1.0'), ('2', 'CAS 2.0'), ('3', 'CAS 3.0'), ('CAS_2_SAML_1_0', 'SAML 1.1')], default='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'),
),
migrations.AlterField(
model_name='servicepattern',
name='single_log_out_callback',
field=models.CharField(blank=True, default='', help_text='URL where the SLO request will be POST. empty = service url\nThis is usefull for non HTTP proxied services.', max_length=255, verbose_name='single log out callback'),
),
migrations.AlterField(
model_name='servicepattern',
name='user_field',
field=models.CharField(blank=True, default='', help_text='Name of the attribute to transmit as username, empty = login', max_length=255, verbose_name='user field'),
),
]
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2017-03-29 15:48
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cas_server', '0012_auto_20170328_1610'),
]
operations = [
migrations.AlterField(
model_name='user',
name='username',
field=models.CharField(max_length=250),
),
]
...@@ -273,7 +273,7 @@ class User(models.Model): ...@@ -273,7 +273,7 @@ class User(models.Model):
#: The session key of the current authenticated user #: The session key of the current authenticated user
session_key = models.CharField(max_length=40, blank=True, null=True) session_key = models.CharField(max_length=40, blank=True, null=True)
#: The username of the current authenticated user #: The username of the current authenticated user
username = models.CharField(max_length=30) username = models.CharField(max_length=250)
#: Last time the authenticated user has do something (auth, fetch ticket, etc…) #: Last time the authenticated user has do something (auth, fetch ticket, etc…)
date = models.DateTimeField(auto_now=True) date = models.DateTimeField(auto_now=True)
#: last time the user logged #: last time the user logged
......
...@@ -58,7 +58,7 @@ ...@@ -58,7 +58,7 @@
class="alert alert-danger" class="alert alert-danger"
{% endif %} {% endif %}
{% endspaceless %}> {% endspaceless %}>
<p>{{message|safe}}</p> <p>{{message}}</p>
</div> </div>
{% endfor %} {% endfor %}
{% if auto_submit %}</noscript>{% endif %} {% if auto_submit %}</noscript>{% endif %}
......
...@@ -2,6 +2,6 @@ ...@@ -2,6 +2,6 @@
{% load staticfiles %} {% load staticfiles %}
{% load i18n %} {% load i18n %}
{% block content %} {% block content %}
<div class="alert alert-success" role="alert">{{logout_msg|safe}}</div> <div class="alert alert-success" role="alert">{{logout_msg}}</div>
{% endblock %} {% endblock %}
...@@ -295,6 +295,24 @@ class LoginTestCase(TestCase, BaseServicePattern, CanLogin): ...@@ -295,6 +295,24 @@ class LoginTestCase(TestCase, BaseServicePattern, CanLogin):
) in response.content ) in response.content
) )
@override_settings(CAS_SHOW_SERVICE_MESSAGES=False)
def test_view_login_get_allowed_service_no_message(self):
"""Request a ticket for an allowed service by an unauthenticated client"""
# get a bare new http client
client = Client()
# we are not authenticated and are asking for a ticket for https://www.example.com
# which is a valid service matched by self.service_pattern
response = client.get("/login?service=https://www.example.com")
# the login page should be displayed
self.assertEqual(response.status_code, 200)
# we warn the user why it need to authenticated
self.assertFalse(
(
b"Authentication required by service "
b"example (https://www.example.com)"
) in response.content
)
def test_view_login_get_denied_service(self): def test_view_login_get_denied_service(self):
"""Request a ticket for an denied service by an unauthenticated client""" """Request a ticket for an denied service by an unauthenticated client"""
# get a bare new http client # get a bare new http client
...@@ -306,6 +324,18 @@ class LoginTestCase(TestCase, BaseServicePattern, CanLogin): ...@@ -306,6 +324,18 @@ class LoginTestCase(TestCase, BaseServicePattern, CanLogin):
# we warn the user that https://www.example.net is not an allowed service url # we warn the user that https://www.example.net is not an allowed service url
self.assertTrue(b"Service https://www.example.net not allowed" in response.content) self.assertTrue(b"Service https://www.example.net not allowed" in response.content)
@override_settings(CAS_SHOW_SERVICE_MESSAGES=False)
def test_view_login_get_denied_service_no_message(self):
"""Request a ticket for an denied service by an unauthenticated client"""
# get a bare new http client
client = Client()
# we are not authenticated and are asking for a ticket for https://www.example.net
# which is NOT a valid service
response = client.get("/login?service=https://www.example.net")
self.assertEqual(response.status_code, 200)
# we warn the user that https://www.example.net is not an allowed service url
self.assertFalse(b"Service https://www.example.net not allowed" in response.content)
def test_view_login_get_auth_allowed_service(self): def test_view_login_get_auth_allowed_service(self):
"""Request a ticket for an allowed service by an authenticated client""" """Request a ticket for an allowed service by an authenticated client"""
# get a client that is already authenticated # get a client that is already authenticated
...@@ -505,6 +535,40 @@ class LoginTestCase(TestCase, BaseServicePattern, CanLogin): ...@@ -505,6 +535,40 @@ class LoginTestCase(TestCase, BaseServicePattern, CanLogin):
# renewing authentication is done in the validate and serviceValidate views tests # renewing authentication is done in the validate and serviceValidate views tests
self.assertEqual(ticket.renew, True) self.assertEqual(ticket.renew, True)
@override_settings(CAS_SHOW_SERVICE_MESSAGES=False)
def test_renew_message_disabled(self):
"""test the authentication renewal request from a service"""
# use the default test service
service = "https://www.example.com"
# get a client that is already authenticated
client = get_auth_client()
# ask for a ticket for the service but aks for authentication renewal
response = client.get("/login", {'service': service, 'renew': 'on'})
# we are ask to reauthenticate and tell the user why
self.assertEqual(response.status_code, 200)
self.assertFalse(
(
b"Authentication renewal required by "
b"service example (https://www.example.com)"
) in response.content
)
# get the form default parameter
params = copy_form(response.context["form"])
# set valid username/password
params["username"] = settings.CAS_TEST_USER
params["password"] = settings.CAS_TEST_PASSWORD
# the renew parameter from the form should be True
self.assertEqual(params["renew"], True)
# post the authentication request
response = client.post("/login", params)
# the request succed, a ticket is created and we are redirected to the service url
self.assertEqual(response.status_code, 302)
ticket_value = response['Location'].split('ticket=')[-1]
ticket = models.ServiceTicket.objects.get(value=ticket_value)
# the created ticket is marked has being gottent after a renew. Futher testing about
# renewing authentication is done in the validate and serviceValidate views tests
self.assertEqual(ticket.renew, True)
@override_settings(CAS_ENABLE_AJAX_AUTH=True) @override_settings(CAS_ENABLE_AJAX_AUTH=True)
def test_ajax_login_required(self): def test_ajax_login_required(self):
""" """
......
...@@ -23,6 +23,7 @@ from django.views.decorators.csrf import csrf_exempt ...@@ -23,6 +23,7 @@ from django.views.decorators.csrf import csrf_exempt
from django.middleware.csrf import CsrfViewMiddleware from django.middleware.csrf import CsrfViewMiddleware
from django.views.generic import View from django.views.generic import View
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
from django.utils.safestring import mark_safe
import re import re
import logging import logging
...@@ -181,24 +182,24 @@ class LogoutView(View, LogoutMixin): ...@@ -181,24 +182,24 @@ class LogoutView(View, LogoutMixin):
else: else:
# build logout message depending of the number of sessions the user logs out # build logout message depending of the number of sessions the user logs out
if session_nb == 1: if session_nb == 1:
logout_msg = _( logout_msg = mark_safe(_(
"<h3>Logout successful</h3>" "<h3>Logout successful</h3>"
"You have successfully logged out from the Central Authentication Service. " "You have successfully logged out from the Central Authentication Service. "
"For security reasons, close your web browser." "For security reasons, close your web browser."
) ))
elif session_nb > 1: elif session_nb > 1:
logout_msg = _( logout_msg = mark_safe(_(
"<h3>Logout successful</h3>" "<h3>Logout successful</h3>"
"You have successfully logged out from %s sessions of the Central " "You have successfully logged out from %d sessions of the Central "
"Authentication Service. " "Authentication Service. "
"For security reasons, close your web browser." "For security reasons, close your web browser."
) % session_nb ) % session_nb)
else: else:
logout_msg = _( logout_msg = mark_safe(_(
"<h3>Logout successful</h3>" "<h3>Logout successful</h3>"
"You were already logged out from the Central Authentication Service. " "You were already logged out from the Central Authentication Service. "
"For security reasons, close your web browser." "For security reasons, close your web browser."
) ))
# depending of settings, redirect to the login page with a logout message or display # depending of settings, redirect to the login page with a logout message or display
# the logout page. The default is to display tge logout page. # the logout page. The default is to display tge logout page.
...@@ -835,26 +836,29 @@ class LoginView(View, LogoutMixin): ...@@ -835,26 +836,29 @@ class LoginView(View, LogoutMixin):
# clean messages before leaving django # clean messages before leaving django
list(messages.get_messages(self.request)) list(messages.get_messages(self.request))
return HttpResponseRedirect(self.service) return HttpResponseRedirect(self.service)
if self.request.session.get("authenticated") and self.renew:
messages.add_message( if settings.CAS_SHOW_SERVICE_MESSAGES:
self.request, if self.request.session.get("authenticated") and self.renew:
messages.WARNING, messages.add_message(
_(u"Authentication renewal required by service %(name)s (%(url)s).") % self.request,
{'name': service_pattern.name, 'url': self.service} messages.WARNING,
) _(u"Authentication renewal required by service %(name)s (%(url)s).") %
else: {'name': service_pattern.name, 'url': self.service}
)
else:
messages.add_message(
self.request,
messages.WARNING,
_(u"Authentication required by service %(name)s (%(url)s).") %
{'name': service_pattern.name, 'url': self.service}
)
except ServicePattern.DoesNotExist:
if settings.CAS_SHOW_SERVICE_MESSAGES:
messages.add_message( messages.add_message(
self.request, self.request,
messages.WARNING, messages.ERROR,
_(u"Authentication required by service %(name)s (%(url)s).") % _(u'Service %s not allowed') % self.service
{'name': service_pattern.name, 'url': self.service}
) )
except ServicePattern.DoesNotExist:
messages.add_message(
self.request,
messages.ERROR,
_(u'Service %s not allowed') % self.service
)
if self.ajax: if self.ajax:
data = { data = {
"status": "error", "status": "error",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment