Add ldap bind auth method and CAS_TGT_VALIDITY parameter. Fix #18

This commit is contained in:
Valentin Samir 2016-10-07 15:22:49 +02:00
parent e77dbbcd03
commit f1fed48b21
12 changed files with 289 additions and 9 deletions

View file

@ -12,6 +12,9 @@ Unreleased
Added Added
----- -----
* Add a test for login with missing parameter (username or password or both) * Add a test for login with missing parameter (username or password or both)
* Add ldap auth using bind method (use the user credentials to bind the the ldap server and let the
server check the credentials)
* Add CAS_TGT_VALIDITY parameter: Max time after with the user MUST reauthenticate.
Fixed Fixed
----- -----

View file

@ -268,6 +268,11 @@ Authentication settings
which inactive users are logged out. The default is ``1209600`` (2 weeks). You probably should which inactive users are logged out. The default is ``1209600`` (2 weeks). You probably should
reduce it to something like ``86400`` seconds (1 day). reduce it to something like ``86400`` seconds (1 day).
* ``CAS_TGT_VALIDITY``: Max time after with the user MUST reauthenticate. Let it to `None` for no
max time.This can be used to force refreshing cached informations only available upon user
authentication like the user attributes in federation mode or with the ldap auth in bind mode.
The default is ``None``.
* ``CAS_PROXY_CA_CERTIFICATE_PATH``: Path to certificate authorities file. Usually on linux * ``CAS_PROXY_CA_CERTIFICATE_PATH``: Path to certificate authorities file. Usually on linux
the local CAs are in ``/etc/ssl/certs/ca-certificates.crt``. The default is ``True`` which the local CAs are in ``/etc/ssl/certs/ca-certificates.crt``. The default is ``True`` which
tell requests to use its internal certificat authorities. Settings it to ``False`` should tell requests to use its internal certificat authorities. Settings it to ``False`` should
@ -416,6 +421,14 @@ Only usefull if you are using the ldap authentication backend:
The hashed password in the database is compare to the hexadecimal digest of the clear The hashed password in the database is compare to the hexadecimal digest of the clear
password hashed with the corresponding algorithm. password hashed with the corresponding algorithm.
* ``"plain"``, the password in the database must be in clear. * ``"plain"``, the password in the database must be in clear.
* ``"bind``, the user credentials are used to bind to the ldap database and retreive the user
attribute. In this mode, the settings ``CAS_LDAP_PASSWORD_ATTR`` and ``CAS_LDAP_PASSWORD_CHARSET``
are ignored, and it is the ldap server that perform password check. The counterpart is that
the user attributes are only available upon user password check and so are cached for later
use. All the other modes directly fetch the user attributes from the database whenever there
are needed. This mean that is you use this mode, they can be some difference between the
attributes in database and the cached ones if changes happend in the database after the user
authentiate. See the parameter ``CAS_TGT_VALIDITY`` to force user to reauthenticate periodically.
The default is ``"ldap"``. The default is ``"ldap"``.
* ``CAS_LDAP_PASSWORD_CHARSET``: Charset the LDAP users passwords was hash with. This is needed to * ``CAS_LDAP_PASSWORD_CHARSET``: Charset the LDAP users passwords was hash with. This is needed to
@ -585,6 +598,10 @@ 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 username and attributes. The user is now logged in on ``django-cas-server`` and can use
services using ``django-cas-server`` as CAS. services using ``django-cas-server`` as CAS.
In federation mode, the user attributes are cached upon user authentication. See the settings
``CAS_TGT_VALIDITY`` to force users to reauthenticate periodically and allow ``django-cas-server``
to refresh cached attributes.
The list of allowed identity providers is defined using the django admin application. 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. With the development server started, visit http://127.0.0.1:8000/admin/ to add identity providers.

View file

@ -9,10 +9,12 @@
# #
# (c) 2015-2016 Valentin Samir # (c) 2015-2016 Valentin Samir
"""module for the admin interface of the app""" """module for the admin interface of the app"""
from .default_settings import settings
from django.contrib import admin from django.contrib import admin
from .models import ServiceTicket, ProxyTicket, ProxyGrantingTicket, User, ServicePattern from .models import ServiceTicket, ProxyTicket, ProxyGrantingTicket, User, ServicePattern
from .models import Username, ReplaceAttributName, ReplaceAttributValue, FilterAttributValue from .models import Username, ReplaceAttributName, ReplaceAttributValue, FilterAttributValue
from .models import FederatedIendityProvider from .models import FederatedIendityProvider, FederatedUser, UserAttributes
from .forms import TicketForm from .forms import TicketForm
@ -167,6 +169,33 @@ class FederatedIendityProviderAdmin(admin.ModelAdmin):
list_display = ('verbose_name', 'suffix', 'display') list_display = ('verbose_name', 'suffix', 'display')
admin.site.register(User, UserAdmin) class FederatedUserAdmin(admin.ModelAdmin):
"""
Bases: :class:`django.contrib.admin.ModelAdmin`
:class:`FederatedUser<cas_server.models.FederatedUser>` in admin
interface
"""
#: Fields to display on a object.
fields = ('username', 'provider', 'last_update')
#: Fields to display on the list of class:`FederatedUserAdmin` objects.
list_display = ('username', 'provider', 'last_update')
class UserAttributesAdmin(admin.ModelAdmin):
"""
Bases: :class:`django.contrib.admin.ModelAdmin`
:class:`UserAttributes<cas_server.models.UserAttributes>` in admin
interface
"""
#: Fields to display on a object.
fields = ('username', '_attributs')
admin.site.register(ServicePattern, ServicePatternAdmin) admin.site.register(ServicePattern, ServicePatternAdmin)
admin.site.register(FederatedIendityProvider, FederatedIendityProviderAdmin) admin.site.register(FederatedIendityProvider, FederatedIendityProviderAdmin)
if settings.DEBUG: # pragma: no branch (we always test with DEBUG True)
admin.site.register(User, UserAdmin)
admin.site.register(FederatedUser, FederatedUserAdmin)
admin.site.register(UserAttributes, UserAttributesAdmin)

View file

@ -30,7 +30,7 @@ try: # pragma: no cover
except ImportError: except ImportError:
ldap3 = None ldap3 = None
from .models import FederatedUser from .models import FederatedUser, UserAttributes
from .utils import check_password, dictfetchall from .utils import check_password, dictfetchall
@ -284,6 +284,10 @@ class LdapAuthUser(DBAuthUser): # pragma: no cover
def __init__(self, username): def __init__(self, username):
if not ldap3: if not ldap3:
raise RuntimeError("Please install ldap3 before using the LdapAuthUser backend") raise RuntimeError("Please install ldap3 before using the LdapAuthUser backend")
if not settings.CAS_LDAP_BASE_DN:
raise ValueError(
"You must define CAS_LDAP_BASE_DN for using the ldap authentication backend"
)
# in case we got deconnected from the database, retry to connect 2 times # in case we got deconnected from the database, retry to connect 2 times
for retry_nb in range(3): for retry_nb in range(3):
try: try:
@ -294,6 +298,8 @@ class LdapAuthUser(DBAuthUser): # pragma: no cover
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() 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])
@ -315,7 +321,34 @@ class LdapAuthUser(DBAuthUser): # pragma: no cover
correct, ``False`` otherwise. correct, ``False`` otherwise.
:rtype: bool :rtype: bool
""" """
if self.user and self.user.get(settings.CAS_LDAP_PASSWORD_ATTR): if settings.CAS_LDAP_PASSWORD_CHECK == "bind":
try:
conn = ldap3.Connection(
settings.CAS_LDAP_SERVER,
self.user["dn"],
password,
auto_bind=True
)
try:
# fetch the user attribute
if conn.search(
settings.CAS_LDAP_BASE_DN,
settings.CAS_LDAP_USER_QUERY % ldap3.utils.conv.escape_bytes(self.username),
attributes=ldap3.ALL_ATTRIBUTES
) and len(conn.entries) == 1:
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
# later.
user = UserAttributes.objects.get_or_create(username=self.username)[0]
user.attributs = attributes
user.save()
finally:
conn.unbind()
return True
except (ldap3.LDAPBindError, ldap3.LDAPCommunicationError):
return False
elif self.user and self.user.get(settings.CAS_LDAP_PASSWORD_ATTR):
return check_password( return check_password(
settings.CAS_LDAP_PASSWORD_CHECK, settings.CAS_LDAP_PASSWORD_CHECK,
password, password,
@ -325,6 +358,22 @@ class LdapAuthUser(DBAuthUser): # pragma: no cover
else: else:
return False return False
def attributs(self):
"""
The user attributes.
:return: a :class:`dict` with the user attributes. Attributes may be :func:`unicode`
or :class:`list` of :func:`unicode`. If the user do not exists, the returned
:class:`dict` is empty.
:rtype: dict
:raises NotImplementedError: if the password check method in `CAS_LDAP_PASSWORD_CHECK`
do not allow to fetch the attributes without the user credentials.
"""
if settings.CAS_LDAP_PASSWORD_CHECK == "bind":
raise NotImplementedError()
else:
return super(LdapAuthUser, self).attributs()
class DjangoAuthUser(AuthUser): # pragma: no cover class DjangoAuthUser(AuthUser): # pragma: no cover
""" """

View file

@ -58,6 +58,10 @@ CAS_SLO_MAX_PARALLEL_REQUESTS = 10
CAS_SLO_TIMEOUT = 5 CAS_SLO_TIMEOUT = 5
#: Shared to transmit then using the view :class:`cas_server.views.Auth` #: Shared to transmit then using the view :class:`cas_server.views.Auth`
CAS_AUTH_SHARED_SECRET = '' CAS_AUTH_SHARED_SECRET = ''
#: Max time after with the user MUST reauthenticate. Let it to `None` for no max time.
#: This can be used to force refreshing cached informations only available upon user authentication
#: like the user attributes in federation mode or with the ldap auth in bind mode.
CAS_TGT_VALIDITY = None
#: Number of seconds the service tickets and proxy tickets are valid. This is the maximal time #: Number of seconds the service tickets and proxy tickets are valid. This is the maximal time

View file

@ -23,4 +23,5 @@ class Command(BaseCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
models.User.clean_deleted_sessions() models.User.clean_deleted_sessions()
models.UserAttributes.clean_old_entries()
models.NewVersionWarning.send_mails() models.NewVersionWarning.send_mails()

View file

@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.1 on 2016-10-07 12:58
from __future__ import unicode_literals
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('cas_server', '0010_auto_20160824_2112'),
]
operations = [
migrations.CreateModel(
name='UserAttributes',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('_attributs', models.TextField(blank=True, default=None, null=True)),
('username', models.CharField(max_length=155, unique=True)),
],
options={
'verbose_name': 'User attributes cache',
'verbose_name_plural': 'User attributes caches',
},
),
migrations.AlterModelOptions(
name='federateduser',
options={'verbose_name': 'Federated user', 'verbose_name_plural': 'Federated users'},
),
migrations.AddField(
model_name='user',
name='last_login',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
]

View file

@ -163,6 +163,8 @@ class FederatedUser(JsonAttributes):
""" """
class Meta: class Meta:
unique_together = ("username", "provider") unique_together = ("username", "provider")
verbose_name = _("Federated user")
verbose_name_plural = _("Federated users")
#: The user username returned by the CAS backend on successful ticket validation #: The user username returned by the CAS backend on successful ticket validation
username = models.CharField(max_length=124) username = models.CharField(max_length=124)
#: A foreign key to :class:`FederatedIendityProvider` #: A foreign key to :class:`FederatedIendityProvider`
@ -233,6 +235,30 @@ class FederateSLO(models.Model):
federate_slo.delete() federate_slo.delete()
@python_2_unicode_compatible
class UserAttributes(JsonAttributes):
"""
Bases: :class:`JsonAttributes`
Local cache of the user attributes, used then needed
"""
class Meta:
verbose_name = _("User attributes cache")
verbose_name_plural = _("User attributes caches")
#: The username of the user for which we cache attributes
username = models.CharField(max_length=155, unique=True)
def __str__(self):
return self.username
@classmethod
def clean_old_entries(cls):
"""Remove :class:`UserAttributes` for which no more :class:`User` exists."""
for user in cls.objects.all():
if User.objects.filter(username=user.username).count() == 0:
user.delete()
@python_2_unicode_compatible @python_2_unicode_compatible
class User(models.Model): class User(models.Model):
""" """
@ -250,6 +276,8 @@ class User(models.Model):
username = models.CharField(max_length=30) username = models.CharField(max_length=30)
#: 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_login = models.DateTimeField(auto_now_add=True)
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
""" """
@ -269,9 +297,12 @@ class User(models.Model):
Remove :class:`User` objects inactive since more that Remove :class:`User` objects inactive since more that
:django:setting:`SESSION_COOKIE_AGE` and send corresponding SingleLogOut requests. :django:setting:`SESSION_COOKIE_AGE` and send corresponding SingleLogOut requests.
""" """
users = cls.objects.filter( filter = Q(date__lt=(timezone.now() - timedelta(seconds=settings.SESSION_COOKIE_AGE)))
date__lt=(timezone.now() - timedelta(seconds=settings.SESSION_COOKIE_AGE)) if settings.CAS_TGT_VALIDITY is not None:
) filter |= Q(
last_login__lt=(timezone.now() - timedelta(seconds=settings.CAS_TGT_VALIDITY))
)
users = cls.objects.filter(filter)
for user in users: for user in users:
user.logout() user.logout()
users.delete() users.delete()
@ -288,9 +319,22 @@ class User(models.Model):
def attributs(self): def attributs(self):
""" """
Property. Property.
A fresh :class:`dict` for the user attributes, using ``settings.CAS_AUTH_CLASS`` A fresh :class:`dict` for the user attributes, using ``settings.CAS_AUTH_CLASS`` if
possible, and if not, try to fallback to cached attributes (actually only used for ldap
auth class with bind password check mthode).
""" """
return utils.import_attr(settings.CAS_AUTH_CLASS)(self.username).attributs() try:
return utils.import_attr(settings.CAS_AUTH_CLASS)(self.username).attributs()
except NotImplementedError:
try:
user = UserAttributes.objects.get(username=self.username)
attributes = user.attributs
if attributes is not None:
return attributes
else:
return {}
except UserAttributes.DoesNotExist:
return {}
def __str__(self): def __str__(self):
return u"%s - %s" % (self.username, self.session_key) return u"%s - %s" % (self.username, self.session_key)

29
cas_server/tests/auth.py Normal file
View file

@ -0,0 +1,29 @@
# -*- 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 cas_server import auth
class TestCachedAttributesAuthUser(auth.TestAuthUser):
"""
A test authentication class only working for one unique user.
:param unicode username: A username, stored in the :attr:`username<AuthUser.username>`
class attribute. The uniq valid value is ``settings.CAS_TEST_USER``.
"""
def attributs(self):
"""
The user attributes.
:raises NotImplementedError: as this class do not support fetching user attributes
"""
raise NotImplementedError()

View file

@ -185,6 +185,17 @@ class UserModels(object):
).update(date=new_date) ).update(date=new_date)
return client return client
@staticmethod
def tgt_expired_user(sec):
"""return a user logged since sec seconds"""
client = get_auth_client()
new_date = timezone.now() - timedelta(seconds=(sec))
models.User.objects.filter(
username=settings.CAS_TEST_USER,
session_key=client.session.session_key
).update(last_login=new_date)
return client
@staticmethod @staticmethod
def get_user(client): def get_user(client):
"""return the user associated with an authenticated client""" """return the user associated with an authenticated client"""

View file

@ -114,6 +114,24 @@ class FederateSLOTestCase(TestCase, UserModels):
models.FederateSLO.objects.get(username="test1@example.com") models.FederateSLO.objects.get(username="test1@example.com")
@override_settings(CAS_AUTH_CLASS='cas_server.auth.TestAuthUser')
class UserAttributesTestCase(TestCase, UserModels):
"""test for the user attributes cache model"""
def test_clean_old_entries(self):
"""test the clean_old_entries methode"""
client = get_auth_client()
user = self.get_user(client)
models.UserAttributes.objects.create(username=settings.CAS_TEST_USER)
# test that attribute cache is removed for non existant users
self.assertEqual(len(models.UserAttributes.objects.all()), 1)
models.UserAttributes.clean_old_entries()
self.assertEqual(len(models.UserAttributes.objects.all()), 1)
user.delete()
models.UserAttributes.clean_old_entries()
self.assertEqual(len(models.UserAttributes.objects.all()), 0)
@override_settings(CAS_AUTH_CLASS='cas_server.auth.TestAuthUser') @override_settings(CAS_AUTH_CLASS='cas_server.auth.TestAuthUser')
class UserTestCase(TestCase, UserModels): class UserTestCase(TestCase, UserModels):
"""tests for the user models""" """tests for the user models"""
@ -144,6 +162,24 @@ class UserTestCase(TestCase, UserModels):
# assert the user has being well delete # assert the user has being well delete
self.assertEqual(len(models.User.objects.all()), 0) self.assertEqual(len(models.User.objects.all()), 0)
@override_settings(CAS_TGT_VALIDITY=3600)
def test_clean_old_entries_tgt_expired(self):
"""test clean_old_entiers with CAS_TGT_VALIDITY set"""
# get an authenticated client
client = self.tgt_expired_user(settings.CAS_TGT_VALIDITY + 60)
# assert the user exists before being cleaned
self.assertEqual(len(models.User.objects.all()), 1)
# assert the last lofin date is before the expiry date
self.assertTrue(
self.get_user(client).last_login < (
timezone.now() - timedelta(seconds=settings.CAS_TGT_VALIDITY)
)
)
# delete old inactive users
models.User.clean_old_entries()
# assert the user has being well delete
self.assertEqual(len(models.User.objects.all()), 0)
def test_clean_deleted_sessions(self): def test_clean_deleted_sessions(self):
"""test clean_deleted_sessions""" """test clean_deleted_sessions"""
# get an authenticated client # get an authenticated client
@ -177,6 +213,24 @@ class UserTestCase(TestCase, UserModels):
self.assertFalse(models.ServiceTicket.objects.all()) self.assertFalse(models.ServiceTicket.objects.all())
self.assertTrue(client2.session.get("authenticated")) self.assertTrue(client2.session.get("authenticated"))
@override_settings(CAS_AUTH_CLASS='cas_server.tests.auth.TestCachedAttributesAuthUser')
def test_cached_attributs(self):
"""
Test gettting user attributes from cache for auth method that do not support direct
fetch (link the ldap bind auth methode)
"""
client = get_auth_client()
user = self.get_user(client)
# if no cache is defined, the attributes are empty
self.assertEqual(user.attributs, {})
user_attr = models.UserAttributes.objects.create(username=settings.CAS_TEST_USER)
# if a cache is defined but without atrributes, also empty
self.assertEqual(user.attributs, {})
user_attr.attributs = settings.CAS_TEST_ATTRIBUTES
user_attr.save()
# attributes are what is found in the cache
self.assertEqual(user.attributs, settings.CAS_TEST_ATTRIBUTES)
@override_settings(CAS_AUTH_CLASS='cas_server.auth.TestAuthUser') @override_settings(CAS_AUTH_CLASS='cas_server.auth.TestAuthUser')
class TicketTestCase(TestCase, UserModels, BaseServicePattern): class TicketTestCase(TestCase, UserModels, BaseServicePattern):

View file

@ -506,6 +506,7 @@ class LoginView(View, LogoutMixin):
username=self.request.session['username'], username=self.request.session['username'],
session_key=self.request.session.session_key session_key=self.request.session.session_key
)[0] )[0]
self.user.last_login = timezone.now()
self.user.save() self.user.save()
elif ret == self.USER_LOGIN_FAILURE: # bad user login elif ret == self.USER_LOGIN_FAILURE: # bad user login
if settings.CAS_FEDERATE: if settings.CAS_FEDERATE: