diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 519c400..1b1caee 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -12,6 +12,9 @@ Unreleased Added ----- * 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 ----- diff --git a/README.rst b/README.rst index 283a5ed..715bf3e 100644 --- a/README.rst +++ b/README.rst @@ -268,6 +268,11 @@ Authentication settings which inactive users are logged out. The default is ``1209600`` (2 weeks). You probably should 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 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 @@ -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 password hashed with the corresponding algorithm. * ``"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"``. * ``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 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. With the development server started, visit http://127.0.0.1:8000/admin/ to add identity providers. diff --git a/cas_server/admin.py b/cas_server/admin.py index 6e5c318..05ba1f5 100644 --- a/cas_server/admin.py +++ b/cas_server/admin.py @@ -9,10 +9,12 @@ # # (c) 2015-2016 Valentin Samir """module for the admin interface of the app""" +from .default_settings import settings + 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 .models import FederatedIendityProvider, FederatedUser, UserAttributes from .forms import TicketForm @@ -167,6 +169,33 @@ class FederatedIendityProviderAdmin(admin.ModelAdmin): list_display = ('verbose_name', 'suffix', 'display') -admin.site.register(User, UserAdmin) +class FederatedUserAdmin(admin.ModelAdmin): + """ + Bases: :class:`django.contrib.admin.ModelAdmin` + + :class:`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` in admin + interface + """ + #: Fields to display on a object. + fields = ('username', '_attributs') + + admin.site.register(ServicePattern, ServicePatternAdmin) 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) diff --git a/cas_server/auth.py b/cas_server/auth.py index fbd199e..bcdce71 100644 --- a/cas_server/auth.py +++ b/cas_server/auth.py @@ -30,7 +30,7 @@ try: # pragma: no cover except ImportError: ldap3 = None -from .models import FederatedUser +from .models import FederatedUser, UserAttributes from .utils import check_password, dictfetchall @@ -284,6 +284,10 @@ class LdapAuthUser(DBAuthUser): # pragma: no cover def __init__(self, username): if not ldap3: 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 for retry_nb in range(3): try: @@ -294,6 +298,8 @@ class LdapAuthUser(DBAuthUser): # pragma: no cover attributes=ldap3.ALL_ATTRIBUTES ) and len(conn.entries) == 1: 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): self.user = user super(LdapAuthUser, self).__init__(user[settings.CAS_LDAP_USERNAME_ATTR][0]) @@ -315,7 +321,34 @@ class LdapAuthUser(DBAuthUser): # pragma: no cover correct, ``False`` otherwise. :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( settings.CAS_LDAP_PASSWORD_CHECK, password, @@ -325,6 +358,22 @@ class LdapAuthUser(DBAuthUser): # pragma: no cover else: 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 """ diff --git a/cas_server/default_settings.py b/cas_server/default_settings.py index 238fc0a..737bb84 100644 --- a/cas_server/default_settings.py +++ b/cas_server/default_settings.py @@ -58,6 +58,10 @@ CAS_SLO_MAX_PARALLEL_REQUESTS = 10 CAS_SLO_TIMEOUT = 5 #: Shared to transmit then using the view :class:`cas_server.views.Auth` 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 diff --git a/cas_server/management/commands/cas_clean_sessions.py b/cas_server/management/commands/cas_clean_sessions.py index 5de4ebf..d0c63fe 100644 --- a/cas_server/management/commands/cas_clean_sessions.py +++ b/cas_server/management/commands/cas_clean_sessions.py @@ -23,4 +23,5 @@ class Command(BaseCommand): def handle(self, *args, **options): models.User.clean_deleted_sessions() + models.UserAttributes.clean_old_entries() models.NewVersionWarning.send_mails() diff --git a/cas_server/migrations/0011_auto_20161007_1258.py b/cas_server/migrations/0011_auto_20161007_1258.py new file mode 100644 index 0000000..e21f8ec --- /dev/null +++ b/cas_server/migrations/0011_auto_20161007_1258.py @@ -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, + ), + ] diff --git a/cas_server/models.py b/cas_server/models.py index 7a421a5..be871b6 100644 --- a/cas_server/models.py +++ b/cas_server/models.py @@ -163,6 +163,8 @@ class FederatedUser(JsonAttributes): """ class Meta: 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 username = models.CharField(max_length=124) #: A foreign key to :class:`FederatedIendityProvider` @@ -233,6 +235,30 @@ class FederateSLO(models.Model): 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 class User(models.Model): """ @@ -250,6 +276,8 @@ class User(models.Model): username = models.CharField(max_length=30) #: Last time the authenticated user has do something (auth, fetch ticket, etc…) date = models.DateTimeField(auto_now=True) + #: last time the user logged + last_login = models.DateTimeField(auto_now_add=True) def delete(self, *args, **kwargs): """ @@ -269,9 +297,12 @@ class User(models.Model): Remove :class:`User` objects inactive since more that :django:setting:`SESSION_COOKIE_AGE` and send corresponding SingleLogOut requests. """ - users = cls.objects.filter( - date__lt=(timezone.now() - timedelta(seconds=settings.SESSION_COOKIE_AGE)) - ) + filter = Q(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: user.logout() users.delete() @@ -288,9 +319,22 @@ class User(models.Model): def attributs(self): """ 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): return u"%s - %s" % (self.username, self.session_key) diff --git a/cas_server/tests/auth.py b/cas_server/tests/auth.py new file mode 100644 index 0000000..436cbce --- /dev/null +++ b/cas_server/tests/auth.py @@ -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` + 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() diff --git a/cas_server/tests/mixin.py b/cas_server/tests/mixin.py index e4a5c0d..d791b53 100644 --- a/cas_server/tests/mixin.py +++ b/cas_server/tests/mixin.py @@ -185,6 +185,17 @@ class UserModels(object): ).update(date=new_date) 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 def get_user(client): """return the user associated with an authenticated client""" diff --git a/cas_server/tests/test_models.py b/cas_server/tests/test_models.py index e0d417e..2139703 100644 --- a/cas_server/tests/test_models.py +++ b/cas_server/tests/test_models.py @@ -114,6 +114,24 @@ class FederateSLOTestCase(TestCase, UserModels): 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') class UserTestCase(TestCase, UserModels): """tests for the user models""" @@ -144,6 +162,24 @@ class UserTestCase(TestCase, UserModels): # assert the user has being well delete 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): """test clean_deleted_sessions""" # get an authenticated client @@ -177,6 +213,24 @@ class UserTestCase(TestCase, UserModels): self.assertFalse(models.ServiceTicket.objects.all()) 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') class TicketTestCase(TestCase, UserModels, BaseServicePattern): diff --git a/cas_server/views.py b/cas_server/views.py index c14e19f..b3d3a1e 100644 --- a/cas_server/views.py +++ b/cas_server/views.py @@ -506,6 +506,7 @@ class LoginView(View, LogoutMixin): username=self.request.session['username'], session_key=self.request.session.session_key )[0] + self.user.last_login = timezone.now() self.user.save() elif ret == self.USER_LOGIN_FAILURE: # bad user login if settings.CAS_FEDERATE: