Add ldap bind auth method and CAS_TGT_VALIDITY parameter. Fix #18
This commit is contained in:
parent
e77dbbcd03
commit
f1fed48b21
@ -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
|
||||
-----
|
||||
|
17
README.rst
17
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.
|
||||
|
||||
|
@ -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<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(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)
|
||||
|
@ -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
|
||||
"""
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
38
cas_server/migrations/0011_auto_20161007_1258.py
Normal file
38
cas_server/migrations/0011_auto_20161007_1258.py
Normal 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,
|
||||
),
|
||||
]
|
@ -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).
|
||||
"""
|
||||
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)
|
||||
|
29
cas_server/tests/auth.py
Normal file
29
cas_server/tests/auth.py
Normal 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()
|
@ -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"""
|
||||
|
@ -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):
|
||||
|
@ -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:
|
||||
|
Loading…
Reference in New Issue
Block a user