Merge pull request #22 from nitmir/dev

Update version to 0.8.0

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
-----
* Allow both unicode and bytes dotted string in utils.import_attr
* Fix some spelling and grammar on log messages. (thanks to Allie Micka)
* Fix froms css class error on success/error due to a scpaless block
* Disable pip cache then installing with make install

Changed
-------
* Update french translation
This commit is contained in:
Valentin Samir 2017-03-08 14:26:09 +01:00 committed by GitHub
commit aa2a35b279
21 changed files with 434 additions and 83 deletions

View File

@ -2,24 +2,25 @@
BASEDIR="$1"
PROJECT_NAME="$2"
cd "$BASEDIR/htmlcov/"; tar czf "$BASEDIR/coverage.tar.gz" ./
cd "$BASEDIR"
TITLE="Coverage report of $PROJECT_NAME"
# build by gitlab CI
if [ -n "$CI_BUILD_REF_NAME" ]; then
BRANCH="$CI_BUILD_REF_NAME"
TITLE="$TITLE, $BRANCH branch"
# build by travis
elif [ -n "$TRAVIS_BRANCH" ]; then
# if this a pull request ?
if [[ "$TRAVIS_PULL_REQUEST" != "false" ]]; then
BRANCH="pull-request-$TRAVIS_PULL_REQUEST"
TITLE="$TITLE, pull request n°$BRANCH"
else
BRANCH="$TRAVIS_BRANCH"
TITLE="$TITLE, $BRANCH branch"
fi
else
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
TITLE="$TITLE, $BRANCH branch"
fi
if [[ "$BRANCH" = "HEAD" ]] || [ -z "$BRANCH" ]; then
@ -27,7 +28,23 @@ if [[ "$BRANCH" = "HEAD" ]] || [ -z "$BRANCH" ]; then
exit 0
fi
curl https://badges.genua.fr/local/coverage/ \
VENV="$(mktemp -d)"
HTMLREPORT="$(mktemp -d)"
virtualenv "$VENV"
"$VENV/bin/pip" install coverage
"$VENV/bin/coverage" html --title "$TITLE" --directory "$HTMLREPORT"
rm -rf "$VENV"
cd "$HTMLREPORT"; tar czf "$BASEDIR/coverage.tar.gz" ./
cd "$BASEDIR"
rm -rf "$HTMLREPORT"
curl https://badges.genua.fr/coverage/ \
-F "secret=$COVERAGE_TOKEN" \
-F "tar=@$BASEDIR/coverage.tar.gz" \
-F "project=$PROJECT_NAME" \

View File

@ -6,6 +6,28 @@ All notable changes to this project will be documented in this file.
.. contents:: Table of Contents
:depth: 2
v0.8.0 - 2017-03-08
===================
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
-----
* Allow both unicode and bytes dotted string in utils.import_attr
* Fix some spelling and grammar on log messages. (thanks to Allie Micka)
* Fix froms css class error on success/error due to a scpaless block
* Disable pip cache then installing with make install
Changed
-------
* Update french translation
v0.7.4 - 2016-09-07
===================

View File

@ -6,7 +6,7 @@ build:
install: dist
pip -V
pip install --no-deps --upgrade --force-reinstall --find-links ./dist/django-cas-server-${VERSION}.tar.gz django-cas-server
pip install --no-cache-dir --no-deps --upgrade --force-reinstall --find-links ./dist/django-cas-server-${VERSION}.tar.gz django-cas-server
uninstall:
pip uninstall django-cas-server || true

View File

@ -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.
@ -638,8 +655,8 @@ You could for example do as bellow::
.. |codacy| image:: https://badges.genua.fr/codacy/grade/255c21623d6946ef8802fa7995b61366/master.svg
:target: https://www.codacy.com/app/valentin-samir/django-cas-server
.. |coverage| image:: https://badges.genua.fr/local/coverage/?project=django-cas-server&branch=master
:target: https://badges.genua.fr/local/coverage/django-cas-server/master
.. |coverage| image:: https://intranet.genua.fr/coverage/badge/django-cas-server/master.svg
:target: https://badges.genua.fr/coverage/django-cas-server/master
.. |doc| image:: https://badges.genua.fr/local/readthedocs/?version=latest
:target: http://django-cas-server.readthedocs.io

View File

@ -11,7 +11,7 @@
"""A django CAS server application"""
#: version of the application
VERSION = '0.7.4'
VERSION = '0.8.0'
#: path the the application configuration class
default_app_config = 'cas_server.apps.CasAppConfig'

View File

@ -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)

View File

@ -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
@ -49,7 +49,7 @@ class AuthUser(object):
def test_password(self, password):
"""
Tests ``password`` agains the user password.
Tests ``password`` against the user-supplied password.
:raises NotImplementedError: always. The method need to be implemented by subclasses
"""
@ -74,7 +74,7 @@ class DummyAuthUser(AuthUser): # pragma: no cover
def test_password(self, password):
"""
Tests ``password`` agains the user password.
Tests ``password`` against the user-supplied password.
:param unicode password: a clear text password as submited by the user.
:return: always ``False``
@ -102,7 +102,7 @@ class TestAuthUser(AuthUser):
def test_password(self, password):
"""
Tests ``password`` agains the user password.
Tests ``password`` against the user-supplied password.
:param unicode password: a clear text password as submited by the user.
:return: ``True`` if :attr:`username<AuthUser.username>` is valid and
@ -149,7 +149,7 @@ class MysqlAuthUser(DBAuthUser): # pragma: no cover
"""
DEPRECATED, use :class:`SqlAuthUser` instead.
A mysql authentication class: authenticate user agains a mysql database
A mysql authentication class: authenticate user against a mysql database
:param unicode username: A username, stored in the :attr:`username<AuthUser.username>`
class attribute. Valid value are fetched from the MySQL database set with
@ -188,7 +188,7 @@ class MysqlAuthUser(DBAuthUser): # pragma: no cover
def test_password(self, password):
"""
Tests ``password`` agains the user password.
Tests ``password`` against the user-supplied password.
:param unicode password: a clear text password as submited by the user.
:return: ``True`` if :attr:`username<AuthUser.username>` is valid and ``password`` is
@ -208,7 +208,7 @@ class MysqlAuthUser(DBAuthUser): # pragma: no cover
class SqlAuthUser(DBAuthUser): # pragma: no cover
"""
A SQL authentication class: authenticate user agains a SQL database. The SQL database
A SQL authentication class: authenticate user against a SQL database. The SQL database
must be configures in settings.py as ``settings.DATABASES['cas_server']``.
:param unicode username: A username, stored in the :attr:`username<AuthUser.username>`
@ -238,7 +238,7 @@ class SqlAuthUser(DBAuthUser): # pragma: no cover
def test_password(self, password):
"""
Tests ``password`` agains the user password.
Tests ``password`` against the user-supplied password.
:param unicode password: a clear text password as submited by the user.
:return: ``True`` if :attr:`username<AuthUser.username>` is valid and ``password`` is
@ -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])
@ -308,14 +314,41 @@ class LdapAuthUser(DBAuthUser): # pragma: no cover
def test_password(self, password):
"""
Tests ``password`` agains the user password.
Tests ``password`` against the user-supplied password.
:param unicode password: a clear text password as submited by the user.
:return: ``True`` if :attr:`username<AuthUser.username>` is valid and ``password`` is
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
"""
@ -347,7 +396,7 @@ class DjangoAuthUser(AuthUser): # pragma: no cover
def test_password(self, password):
"""
Tests ``password`` agains the user password.
Tests ``password`` against the user-supplied password.
:param unicode password: a clear text password as submited by the user.
:return: ``True`` if :attr:`user` is valid and ``password`` is
@ -426,7 +475,7 @@ class CASFederateAuth(AuthUser):
def test_password(self, ticket):
"""
Tests ``password`` agains the user password.
Tests ``password`` against the user-supplied password.
:param unicode password: The CAS tickets just used to validate the user authentication
against its CAS backend.

View File

@ -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

View File

@ -69,7 +69,7 @@ class CASFederateValidateUser(object):
def verify_ticket(self, ticket):
"""
test ``ticket`` agains the CAS provider, if valid, create a
test ``ticket`` against the CAS provider, if valid, create a
:class:`FederatedUser<cas_server.models.FederatedUser>` matching provider returned
username and attributes.

View File

@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: cas_server\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2016-08-24 17:18+0200\n"
"PO-Revision-Date: 2016-08-24 17:18+0200\n"
"POT-Creation-Date: 2016-09-18 11:29+0200\n"
"PO-Revision-Date: 2016-09-18 11:30+0200\n"
"Last-Translator: Valentin Samir <valentin.samir@crans.org>\n"
"Language-Team: django <LL@li.org>\n"
"Language: fr\n"
@ -16,7 +16,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Generator: Poedit 1.8.8\n"
"X-Generator: Poedit 1.8.9\n"
#: apps.py:25 templates/cas_server/base.html:7
#: templates/cas_server/base.html:26
@ -34,37 +34,37 @@ msgstr ""
"identifiant et votre mot de passe chaque fois que vous changez de site, "
"jusqu'à ce que votre session expire ou que vous vous déconnectiez."
#: forms.py:84
#: forms.py:85
msgid "Identity provider"
msgstr "fournisseur d'identité"
#: forms.py:88 forms.py:107
#: forms.py:89 forms.py:111
msgid "Warn me before logging me into other sites."
msgstr "Prévenez-moi avant d'accéder à d'autres services."
#: forms.py:92
#: forms.py:93
msgid "Remember the identity provider"
msgstr "Se souvenir du fournisseur d'identité"
#: forms.py:102 models.py:594
#: forms.py:104 models.py:594
msgid "username"
msgstr "nom d'utilisateur"
#: forms.py:104
#: forms.py:108
msgid "password"
msgstr "mot de passe"
#: forms.py:126
#: forms.py:131
msgid "The credentials you provided cannot be determined to be authentic."
msgstr "Les informations transmises n'ont pas permis de vous authentifier."
#: forms.py:178
#: forms.py:183
msgid "User not found in the temporary database, please try to reconnect"
msgstr ""
"Utilisateur non trouvé dans la base de donnée temporaire, essayez de vous "
"reconnecter"
#: forms.py:192
#: forms.py:197
msgid "service"
msgstr "service"
@ -331,7 +331,7 @@ msgstr "Connexion"
msgid "Connect to the service"
msgstr "Se connecter au service"
#: utils.py:736
#: utils.py:744
#, python-format
msgid "\"%(value)s\" is not a valid regular expression"
msgstr "\"%(value)s\" n'est pas une expression rationnelle valide"
@ -339,7 +339,7 @@ msgstr "\"%(value)s\" n'est pas une expression rationnelle valide"
#: views.py:185
msgid ""
"<h3>Logout successful</h3>You have successfully logged out from the Central "
"Authentication Service. For security reasons, exit your web browser."
"Authentication Service. For security reasons, close your web browser."
msgstr ""
"<h3>Déconnexion réussie</h3>Vous vous êtes déconnecté(e) du Service Central "
"d'Authentification. Pour des raisons de sécurité, veuillez fermer votre "
@ -349,7 +349,7 @@ msgstr ""
#, python-format
msgid ""
"<h3>Logout successful</h3>You have successfully logged out from %s sessions "
"of the Central Authentication Service. For security reasons, exit your web "
"of the Central Authentication Service. For security reasons, close your web "
"browser."
msgstr ""
"<h3>Déconnexion réussie</h3>Vous vous êtes déconnecté(e) de %s sessions du "
@ -359,7 +359,7 @@ msgstr ""
#: views.py:198
msgid ""
"<h3>Logout successful</h3>You were already logged out from the Central "
"Authentication Service. For security reasons, exit your web browser."
"Authentication Service. For security reasons, close your web browser."
msgstr ""
"<h3>Déconnexion réussie</h3>Vous étiez déjà déconnecté(e) du Service Central "
"d'Authentification. Pour des raisons de sécurité, veuillez fermer votre "
@ -375,7 +375,7 @@ msgstr ""
"ticket %(ticket)s: %(error)r"
#: views.py:500
msgid "Invalid login ticket, please retry to login"
msgid "Invalid login ticket, please try to log in again"
msgstr "Ticket de connexion invalide, merci de réessayé de vous connecter"
#: views.py:692

View File

@ -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()

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:
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)
@ -433,7 +477,7 @@ class ServicePattern(models.Model):
"""
Bases: :class:`django.db.models.Model`
Allowed services pattern agains services are tested to
Allowed services pattern against services are tested to
"""
class Meta:
ordering = ("pos", )

View File

@ -6,13 +6,13 @@
</div>
{% endfor %}
{% for field in form %}{% if not field|is_hidden %}
<div class="form-group{% spaceless %}
<div class="form-group
{% if not form.non_field_errors %}
{% if field.errors %} has-error
{% elif form.cleaned_data %} has-success
{% endif %}
{% endif %}"
{% endspaceless %}>{% spaceless %}
>{% spaceless %}
{% if field|is_checkbox %}
<div class="checkbox"><label for="{{field.auto_id}}">{{field}}{{field.label}}</label></div>
{% else %}

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
"""Some test authentication classes for the CAS"""
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)
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"""

View File

@ -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):

View File

@ -132,6 +132,37 @@ class LoginTestCase(TestCase, BaseServicePattern, CanLogin):
# The LoginTicket is conssumed and should no longer be valid
self.assertTrue(params['lt'] not in client.session['lt'])
def test_login_post_missing_params(self):
"""Test a login attempt with missing POST parameters (username or password or both)"""
# we get a client who fetch a frist time the login page and the login form default
# parameters
client, params = get_login_page_params()
# we set only set username
params["username"] = settings.CAS_TEST_USER
# we post a login attempt
response = client.post('/login', params)
# as the LT is not valid, login should fail
self.assert_login_failed(client, response)
# we get a client who fetch a frist time the login page and the login form default
# parameters
client, params = get_login_page_params()
# we set only set password
params["password"] = settings.CAS_TEST_PASSWORD
# we post a login attempt
response = client.post('/login', params)
# as the LT is not valid, login should fail
self.assert_login_failed(client, response)
# we get a client who fetch a frist time the login page and the login form default
# parameters
client, params = get_login_page_params()
# we set neither username nor password
# we post a login attempt
response = client.post('/login', params)
# as the LT is not valid, login should fail
self.assert_login_failed(client, response)
def test_login_view_post_goodpass_goodlt_warn(self):
"""Test a successul login requesting to be warned before creating services tickets"""
# get a client and initial login params
@ -794,7 +825,7 @@ class LogoutTestCase(TestCase):
@override_settings(CAS_ENABLE_AJAX_AUTH=True)
def test_ajax_logout(self):
"""
test ajax logout. These methode are here, but I do not really see an use case for
test ajax logout. These methods are here, but I do not really see an use case for
javascript logout
"""
# get a client that is authenticated
@ -1697,7 +1728,7 @@ class ProxyTestCase(TestCase, BaseServicePattern, XmlContent):
self.assert_error(
response,
"UNAUTHORIZED_SERVICE",
'the service %s do not allow proxy ticket' % params['service']
'the service %s does not allow proxy tickets' % params['service']
)
self.service_pattern.proxy = True
@ -1943,7 +1974,7 @@ class SamlValidateTestCase(TestCase, BaseServicePattern, XmlContent):
self.assert_error(
response,
"AuthnFailed",
'TARGET %s do not match ticket service' % bad_target
'TARGET %s does not match ticket service' % bad_target
)
def test_saml_bad_xml(self):

View File

@ -117,14 +117,18 @@ def import_attr(path):
transform a python dotted path to the attr
:param path: A dotted path to a python object or a python object
:type path: :obj:`unicode` or anything
:type path: :obj:`unicode` or :obj:`str` or anything
:return: The python object pointed by the dotted path or the python object unchanged
"""
if not isinstance(path, str):
# if we got a str, decode it to unicode (normally it should only contain ascii)
if isinstance(path, six.binary_type):
path = path.decode("utf-8")
# if path is not an unicode, return it unchanged (may be it is already the attribute to import)
if not isinstance(path, six.text_type):
return path
if "." not in path:
if u"." not in path:
ValueError("%r should be of the form `module.attr` and we just got `attr`" % path)
module, attr = path.rsplit('.', 1)
module, attr = path.rsplit(u'.', 1)
try:
return getattr(import_module(module), attr)
except ImportError:

View File

@ -61,7 +61,7 @@ class LogoutMixin(object):
username = self.request.session.get("username")
if username:
if all_session:
logger.info("Logging out user %s from all of they sessions." % username)
logger.info("Logging out user %s from all sessions." % username)
else:
logger.info("Logging out user %s." % username)
users = []
@ -143,7 +143,7 @@ class LogoutView(View, LogoutMixin):
def get(self, request, *args, **kwargs):
"""
methode called on GET request on this view
method called on GET request on this view
:param django.http.HttpRequest request: The current request object
"""
@ -184,20 +184,20 @@ class LogoutView(View, LogoutMixin):
logout_msg = _(
"<h3>Logout successful</h3>"
"You have successfully logged out from the Central Authentication Service. "
"For security reasons, exit your web browser."
"For security reasons, close your web browser."
)
elif session_nb > 1:
logout_msg = _(
"<h3>Logout successful</h3>"
"You have successfully logged out from %s sessions of the Central "
"Authentication Service. "
"For security reasons, exit your web browser."
"For security reasons, close your web browser."
) % session_nb
else:
logout_msg = _(
"<h3>Logout successful</h3>"
"You were already logged out from the Central Authentication Service. "
"For security reasons, exit your web browser."
"For security reasons, close your web browser."
)
# depending of settings, redirect to the login page with a logout message or display
@ -229,7 +229,7 @@ class LogoutView(View, LogoutMixin):
class FederateAuth(CsrfExemptView):
"""
view to authenticated user agains a backend CAS then CAS_FEDERATE is True
view to authenticated user against a backend CAS then CAS_FEDERATE is True
csrf is disabled for allowing SLO requests reception.
"""
@ -261,7 +261,7 @@ class FederateAuth(CsrfExemptView):
"""
# if settings.CAS_FEDERATE is not True redirect to the login page
if not settings.CAS_FEDERATE:
logger.warning("CAS_FEDERATE is False, set it to True to use the federated mode")
logger.warning("CAS_FEDERATE is False, set it to True to use federation")
return redirect("cas_server:login")
# POST with a provider suffix, this is probably an SLO request. csrf is disabled for
# allowing SLO requests reception
@ -305,13 +305,13 @@ class FederateAuth(CsrfExemptView):
"""
# if settings.CAS_FEDERATE is not True redirect to the login page
if not settings.CAS_FEDERATE:
logger.warning("CAS_FEDERATE is False, set it to True to use the federated mode")
logger.warning("CAS_FEDERATE is False, set it to True to use federation")
return redirect("cas_server:login")
renew = bool(request.GET.get('renew') and request.GET['renew'] != "False")
# Is the user is already authenticated, no need to request authentication to the user
# identity provider.
if self.request.session.get("authenticated") and not renew:
logger.warning("User already authenticated, dropping federate authentication request")
logger.warning("User already authenticated, dropping federated authentication request")
return redirect("cas_server:login")
try:
# get the identity provider from its suffix
@ -320,7 +320,7 @@ class FederateAuth(CsrfExemptView):
auth = self.get_cas_client(request, provider, renew)
# if no ticket submited, redirect to the identity provider CAS login page
if 'ticket' not in request.GET:
logger.info("Trying to authenticate again %s" % auth.provider.server_url)
logger.info("Trying to authenticate %s again" % auth.provider.server_url)
return HttpResponseRedirect(auth.get_login_url())
else:
ticket = request.GET['ticket']
@ -360,8 +360,8 @@ class FederateAuth(CsrfExemptView):
else:
logger.info(
(
"Got a invalid ticket %s from %s for service %s. "
"Retrying to authenticate"
"Got an invalid ticket %s from %s for service %s. "
"Retrying authentication"
) % (
ticket,
auth.provider.server_url,
@ -485,7 +485,7 @@ class LoginView(View, LogoutMixin):
def post(self, request, *args, **kwargs):
"""
methode called on POST request on this view
method called on POST request on this view
:param django.http.HttpRequest request: The current request object
"""
@ -497,7 +497,7 @@ class LoginView(View, LogoutMixin):
messages.add_message(
self.request,
messages.ERROR,
_(u"Invalid login ticket, please retry to login")
_(u"Invalid login ticket, please try to log in again")
)
elif ret == self.USER_LOGIN_OK:
# On successful login, update the :class:`models.User<cas_server.models.User>` ``date``
@ -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:
@ -554,7 +555,7 @@ class LoginView(View, LogoutMixin):
"""
if not self.check_lt():
self.init_form(self.request.POST)
logger.warning("Receive an invalid login ticket")
logger.warning("Received an invalid login ticket")
return self.INVALID_LOGIN_TICKET
elif not self.request.session.get("authenticated") or self.renew:
# authentication request receive, initialize the form to use
@ -569,10 +570,10 @@ class LoginView(View, LogoutMixin):
logger.info("User %s successfully authenticated" % self.request.session["username"])
return self.USER_LOGIN_OK
else:
logger.warning("A logging attemps failed")
logger.warning("A login attempt failed")
return self.USER_LOGIN_FAILURE
else:
logger.warning("Receuve a logging attempt whereas the user is already logged")
logger.warning("Received a login attempt for an already-active user")
return self.USER_ALREADY_LOGGED
def init_get(self, request):
@ -600,7 +601,7 @@ class LoginView(View, LogoutMixin):
def get(self, request, *args, **kwargs):
"""
methode called on GET request on this view
method called on GET request on this view
:param django.http.HttpRequest request: The current request object
"""
@ -667,7 +668,7 @@ class LoginView(View, LogoutMixin):
def service_login(self):
"""
Perform login agains a service
Perform login against a service
:return:
* The rendering of the ``settings.CAS_WARN_TEMPLATE`` if the user asked to be
@ -801,7 +802,7 @@ class LoginView(View, LogoutMixin):
else:
return utils.redirect_params("cas_server:login", params=self.request.GET)
# if login agains a service
# if login against a service
if self.service:
return self.service_login()
# else display the logged template
@ -949,7 +950,7 @@ class Auth(CsrfExemptView):
@staticmethod
def post(request):
"""
methode called on POST request on this view
method called on POST request on this view
:param django.http.HttpRequest request: The current request object
:return: ``HttpResponse(u"yes\\n")`` if the POSTed tuple (username, password, service)
@ -1005,7 +1006,7 @@ class Validate(View):
@staticmethod
def get(request):
"""
methode called on GET request on this view
method called on GET request on this view
:param django.http.HttpRequest request: The current request object
:return:
@ -1116,7 +1117,7 @@ class ValidateService(View):
def get(self, request):
"""
methode called on GET request on this view
method called on GET request on this view
:param django.http.HttpRequest request: The current request object:
:return: The rendering of ``cas_server/serviceValidate.xml`` if no errors is raised,
@ -1284,7 +1285,7 @@ class Proxy(View):
def get(self, request):
"""
methode called on GET request on this view
method called on GET request on this view
:param django.http.HttpRequest request: The current request object:
:return: The returned value of :meth:`process_proxy` if no error is raised,
@ -1323,7 +1324,7 @@ class Proxy(View):
if not pattern.proxy:
raise ValidateError(
u'UNAUTHORIZED_SERVICE',
u'the service %s do not allow proxy ticket' % self.target_service
u'the service %s does not allow proxy tickets' % self.target_service
)
# is the proxy granting ticket valid
ticket = ProxyGrantingTicket.get(self.pgt)
@ -1387,7 +1388,7 @@ class SamlValidate(CsrfExemptView):
def post(self, request):
"""
methode called on POST request on this view
method called on POST request on this view
:param django.http.HttpRequest request: The current request object
:return: the rendering of ``cas_server/samlValidate.xml`` if no error is raised,
@ -1417,7 +1418,7 @@ class SamlValidate(CsrfExemptView):
)
)
logger.debug(
"SamlValidate: User attributs are:\n%s" % pprint.pformat(self.ticket.attributs)
"SamlValidate: User attributes are:\n%s" % pprint.pformat(self.ticket.attributs)
)
return render(
@ -1446,7 +1447,7 @@ class SamlValidate(CsrfExemptView):
if ticket.service != self.target:
raise SamlValidateError(
u'AuthnFailed',
u'TARGET %s do not match ticket service' % self.target
u'TARGET %s does not match ticket service' % self.target
)
return ticket
except (IndexError, KeyError):