diff --git a/.travis.yml b/.travis.yml index e7583b2..70393e6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,6 @@ language: python matrix: include: - - python: "2.7" - env: TOX_ENV=coverage - python: "2.7" env: TOX_ENV=flake8 - python: "2.7" @@ -23,6 +21,8 @@ matrix: env: TOX_ENV=py35-django18 - python: "3.5" env: TOX_ENV=py35-django19 + - python: "2.7" + env: TOX_ENV=coverage cache: directories: - $HOME/.cache/pip/http/ diff --git a/Makefile b/Makefile index c719834..08d013f 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ dist: test_venv/bin/python: virtualenv test_venv - test_venv/bin/pip install -U --requirement requirements-dev.txt Django + test_venv/bin/pip install -U --requirement requirements-dev.txt 'Django<1.10' test_venv/cas/manage.py: test_venv mkdir -p test_venv/cas @@ -62,7 +62,7 @@ run_server: test_project run_tests: test_venv python setup.py check --restructuredtext --stric - test_venv/bin/py.test --cov=cas_server --cov-report html + test_venv/bin/py.test -rw -x --cov=cas_server --cov-report html rm htmlcov/coverage_html.js # I am really pissed off by those keybord shortcuts test_venv/bin/sphinx-build: test_venv diff --git a/README.rst b/README.rst index 59b9ba1..c4d9e48 100644 --- a/README.rst +++ b/README.rst @@ -29,11 +29,49 @@ Dependencies ``django-cas-server`` depends on the following python packages: -* Django >= 1.7 < 1.10 +* Django >= 1.7.1 < 1.10 * requests >= 2.4 * requests_futures >= 0.9.5 * lxml >= 3.4 -* six >= 1 +* six >= 1.8 + +Minimal version of packages dependancy are just indicative and meens that ``django-cas-server`` has +been tested with it. Previous versions of dependencies may or may not work. + +Additionally, denpending of the authentication backend you plan to use, you may need the following +python packages: + +* ldap3 +* psycopg2 +* mysql-python + + +Here there is a table with the name of python packages and the corresponding packages providing +them on debian like systems and centos like systems. +You should try as much as possible to use system packages as there are automatically updated then +you update your system. You can then install Not Available (N/A) +packages on your system using pip inside a virtualenv as described in the `Installation`_ section. +For use with python3, just replace python(2) in the table by python3. + ++------------------+-------------------------+---------------------+ +| python package | debian like systems | centos like systems | ++==================+=========================+=====================+ +| Django | python-django | python-django | ++------------------+-------------------------+---------------------+ +| requests | python-requests | python-requests | ++------------------+-------------------------+---------------------+ +| requests_futures | python-requests-futures | N/A | ++------------------+-------------------------+---------------------+ +| lxml | python-lxml | python-lxml | ++------------------+-------------------------+---------------------+ +| six | python-six | python-six | ++------------------+-------------------------+---------------------+ +| ldap3 | python-ldap3 | python-ldap3 | ++------------------+-------------------------+---------------------+ +| psycopg2 | python-psycopg2 | python-psycopg2 | ++------------------+-------------------------+---------------------+ +| mysql-python | python-mysqldb | python2-mysql | ++------------------+-------------------------+---------------------+ Installation ============ @@ -63,14 +101,17 @@ The recommended installation mode is to use a virtualenv with ``--system-site-pa New python executable in cas/bin/python2 Also creating executable in cas/bin/python Installing setuptools, pip...done. + +4. And `activate it `__:: + $ cd cas_venv/; . bin/activate -4. Create a django project:: +5. Create a django project:: $ django-admin startproject cas_project $ cd cas_project -5. Install `django-cas-server`. To use the last published release, run:: +6. Install `django-cas-server`. To use the last published release, run:: $ pip install django-cas-server @@ -81,11 +122,11 @@ The recommended installation mode is to use a virtualenv with ``--system-site-pa $ pip install -r requirements.txt Then, either run ``make install`` to create a python package using the sources of the repository - and install it with pip, or place the `cas_server` directory into your + and install it with pip, or place the ``cas_server`` directory into your `PYTHONPATH `_ - (for instance by symlinking `cas_server` to the root of your django project). + (for instance by symlinking ``cas_server`` to the root of your django project). -6. Open ``cas_project/settings.py`` in you favourite editor and follow the quick start section. +7. Open ``cas_project/settings.py`` in you favourite editor and follow the quick start section. Quick start @@ -145,7 +186,7 @@ Quick start 6. Start the development server and visit http://127.0.0.1:8000/admin/ to add a first service allowed to authenticate user against the CAS - (you'll need the Admin app enabled). See the Service Patterns section bellow. + (you'll need the Admin app enabled). See the `Service Patterns`_ section bellow. 7. Visit http://127.0.0.1:8000/cas/ to login with your django users. @@ -163,6 +204,8 @@ Template settings * ``CAS_LOGO_URL``: URL to the logo showed in the up left corner on the default templates. Set it to ``False`` to disable it. +* ``CAS_FAVICON_URL``: URL to the favicon (shortcut icon) used by the default templates. + Default is a key icon. Set it to ``False`` to disable it. * ``CAS_COMPONENT_URLS``: URLs to css and javascript external components. It is a dictionnary and it must have the five following keys: ``"bootstrap3_css"``, ``"bootstrap3_js"``, ``"html5shiv"``, ``"respond"``, ``"jquery"``. The default is:: @@ -191,12 +234,14 @@ Template settings Authentication settings ----------------------- -* ``CAS_AUTH_CLASS``: A dotted path to a class or a class implementing - ``cas_server.auth.AuthUser``. The default is ``"cas_server.auth.DjangoAuthUser"`` +* ``CAS_AUTH_CLASS``: A dotted path to a class or a class implementing + ``cas_server.auth.AuthUser``. The default is ``"cas_server.auth.DjangoAuthUser"`` + Available classes bundled with ``django-cas-server`` are listed below in the + `Authentication backend`_ section. -* ``SESSION_COOKIE_AGE``: This is a django settings. Here, it control the delay in seconds after - which inactive users are logged out. The default is ``1209600`` (2 weeks). You probably should - reduce it to something like ``86400`` seconds (1 day). +* ``SESSION_COOKIE_AGE``: This is a django settings. Here, it control the delay in seconds after + 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_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 @@ -212,13 +257,23 @@ Authentication settings Federation settings ------------------- -* ``CAS_FEDERATE``: A boolean for activating the federated mode (see the federate section below). - The default is ``False``. +* ``CAS_FEDERATE``: A boolean for activating the federated mode (see the `Federation mode`_ + section below). The default is ``False``. * ``CAS_FEDERATE_REMEMBER_TIMEOUT``: Time after witch the cookie use for "remember my identity provider" expire. The default is ``604800``, one week. The cookie is called ``_remember_provider``. +New version warnings settings +----------------------------- + +* ``CAS_NEW_VERSION_HTML_WARNING``: A boolean for diplaying a warning on html pages then a new + version of the application is avaible. Once closed by a user, it is not displayed to this user + until the next new version. The default is ``True``. +* ``CAS_NEW_VERSION_EMAIL_WARNING``: A bolean sot sending a email to ``settings.ADMINS`` when a new + version is available. The default is ``True``. + + Tickets validity settings ------------------------- @@ -257,6 +312,7 @@ Tickets miscellaneous settings Mysql backend settings ---------------------- +Deprecated, see the `Sql backend settings`_. Only usefull if you are using the mysql authentication backend: * ``CAS_SQL_HOST``: Host for the SQL server. The default is ``"localhost"``. @@ -283,6 +339,64 @@ Only usefull if you are using the mysql authentication backend: The default is ``"crypt"``. +Sql backend settings +-------------------- +Only usefull if you are using the sql authentication backend. You must add a ``"cas_server"`` +database to `settings.DATABASES `__ +as defined in the django documentation. It is then the database +use by the sql backend. + +* ``CAS_SQL_USER_QUERY``: The query performed upon user authentication. + The username must be in field ``username``, the password in ``password``, + additional fields are used as the user attributes. + The default is ``"SELECT user AS username, pass AS password, users.* FROM users WHERE user = %s"`` +* ``CAS_SQL_PASSWORD_CHECK``: The method used to check the user password. Must be one of the following: + + * ``"crypt"`` (see ), the password in the database + should begin this $ + * ``"ldap"`` (see https://tools.ietf.org/id/draft-stroeder-hashed-userpassword-values-01.html) + the password in the database must begin with one of {MD5}, {SMD5}, {SHA}, {SSHA}, {SHA256}, + {SSHA256}, {SHA384}, {SSHA384}, {SHA512}, {SSHA512}, {CRYPT}. + * ``"hex_HASH_NAME"`` with ``HASH_NAME`` in md5, sha1, sha224, sha256, sha384, sha512. + 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. + + The default is ``"crypt"``. +* ``CAS_SQL_PASSWORD_CHARSET``: Charset the SQL users passwords was hash with. This is needed to + encode the user sended password before hashing it for comparison. The default is ``"utf-8"``. + + +Ldap backend settings +--------------------- +Only usefull if you are using the ldap authentication backend: + +* ``CAS_LDAP_SERVER``: Address of the LDAP server. The default is ``"localhost"``. +* ``CAS_LDAP_USER``: User bind address, for example ``"cn=admin,dc=crans,dc=org"`` for + connecting to the LDAP server. +* ``CAS_LDAP_PASSWORD``: Password for connecting to the LDAP server. +* ``CAS_LDAP_BASE_DN``: LDAP search base DN, for example ``"ou=data,dc=crans,dc=org"``. +* ``CAS_LDAP_USER_QUERY``: Search filter for searching user by username. User inputed usernames are + escaped using ``ldap3.utils.conv.escape_bytes``. The default is ``"(uid=%s)"`` +* ``CAS_LDAP_USERNAME_ATTR``: Attribute used for users usernames. The default is ``"uid"`` +* ``CAS_LDAP_PASSWORD_ATTR``: Attribute used for users passwords. The default is ``"userPassword"`` +* ``CAS_LDAP_PASSWORD_CHECK``: The method used to check the user password. Must be one of the following: + + * ``"crypt"`` (see ), the password in the database + should begin this $ + * ``"ldap"`` (see https://tools.ietf.org/id/draft-stroeder-hashed-userpassword-values-01.html) + the password in the database must begin with one of {MD5}, {SMD5}, {SHA}, {SSHA}, {SHA256}, + {SSHA256}, {SHA384}, {SSHA384}, {SHA512}, {SSHA512}, {CRYPT}. + * ``"hex_HASH_NAME"`` with ``HASH_NAME`` in md5, sha1, sha224, sha256, sha384, sha512. + 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. + + The default is ``"ldap"``. +* ``CAS_LDAP_PASSWORD_CHARSET``: Charset the LDAP users passwords was hash with. This is needed to + encode the user sended password before hashing it for comparison. The default is ``"utf-8"``. + + Test backend settings --------------------- Only usefull if you are using the test authentication backend: @@ -304,11 +418,17 @@ Authentication backend for the user are defined by the ``CAS_TEST_*`` settings. * django backend ``cas_server.auth.DjangoAuthUser``: Users are authenticated against django users system. This is the default backend. The returned attributes are the fields available on the user model. -* mysql backend ``cas_server.auth.MysqlAuthUser``: see the 'Mysql backend settings' section. +* mysql backend ``cas_server.auth.MysqlAuthUser``: Deprecated, use the sql backend instead. + see the `Mysql backend settings`_ section. The returned attributes are those return by sql query + ``CAS_SQL_USER_QUERY``. +* sql backend ``cas_server.auth.SqlAuthUser``: see the `Sql backend settings`_ section. The returned attributes are those return by sql query ``CAS_SQL_USER_QUERY``. +* ldap backend ``cas_server.auth.LdapAuthUser``: see the `Ldap backend settings`_ section. + The returned attributes are those of the ldap node returned by the query filter ``CAS_LDAP_USER_QUERY``. * federated backend ``cas_server.auth.CASFederateAuth``: It is automatically used then ``CAS_FEDERATE`` is ``True``. You should not set it manually without setting ``CAS_FEDERATE`` to ``True``. + Logs ==== diff --git a/cas_server/__init__.py b/cas_server/__init__.py index 085927b..8d6dd5d 100644 --- a/cas_server/__init__.py +++ b/cas_server/__init__.py @@ -9,5 +9,9 @@ # # (c) 2015-2016 Valentin Samir """A django CAS server application""" + +#: version of the application +VERSION = '0.6.2' + #: path the the application configuration class default_app_config = 'cas_server.apps.CasAppConfig' diff --git a/cas_server/auth.py b/cas_server/auth.py index 31aa4f2..ab0c664 100644 --- a/cas_server/auth.py +++ b/cas_server/auth.py @@ -13,16 +13,25 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.utils import timezone +from django.db import connections, DatabaseError +import warnings from datetime import timedelta +from six.moves import range try: # pragma: no cover import MySQLdb import MySQLdb.cursors - from utils import check_password except ImportError: MySQLdb = None + +try: # pragma: no cover + import ldap3 +except ImportError: + ldap3 = None + from .models import FederatedUser +from .utils import check_password, dictfetchall class AuthUser(object): @@ -116,19 +125,46 @@ class TestAuthUser(AuthUser): return {} -class MysqlAuthUser(AuthUser): # pragma: no cover +class DBAuthUser(AuthUser): # pragma: no cover + """base class for databate based auth classes""" + #: DB user attributes as a :class:`dict` if the username is found in the database. + user = None + + 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 + """ + if self.user: + return self.user + else: + return {} + + +class MysqlAuthUser(DBAuthUser): # pragma: no cover """ - A mysql authentication class: authentication user agains a mysql database + DEPRECATED, use :class:`SqlAuthUser` instead. + + A mysql authentication class: authenticate user agains a mysql database :param unicode username: A username, stored in the :attr:`username` class attribute. Valid value are fetched from the MySQL database set with ``settings.CAS_SQL_*`` settings parameters using the query ``settings.CAS_SQL_USER_QUERY``. """ - #: Mysql user attributes as a :class:`dict` if the username is found in the database. - user = None def __init__(self, username): + warnings.warn( + ( + "MysqlAuthUser authentication class is deprecated: " + "use cas_server.auth.SqlAuthUser instead" + ), + UserWarning + ) # see the connect function at # http://mysql-python.sourceforge.net/MySQLdb.html#functions-and-attributes # for possible mysql config parameters. @@ -169,24 +205,130 @@ class MysqlAuthUser(AuthUser): # 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 +class SqlAuthUser(DBAuthUser): # pragma: no cover + """ + A SQL authentication class: authenticate user agains 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` + class attribute. Valid value are fetched from the MySQL database set with + ``settings.CAS_SQL_*`` settings parameters using the query + ``settings.CAS_SQL_USER_QUERY``. + """ + + def __init__(self, username): + if "cas_server" not in connections: + raise RuntimeError("Please configure the 'cas_server' database in settings.DATABASES") + for retry_nb in range(3): + try: + with connections["cas_server"].cursor() as curs: + curs.execute(settings.CAS_SQL_USER_QUERY, (username,)) + results = dictfetchall(curs) + if len(results) == 1: + self.user = results[0] + super(SqlAuthUser, self).__init__(self.user['username']) + else: + super(SqlAuthUser, self).__init__(username) + break + except DatabaseError: + connections["cas_server"].close() + if retry_nb == 2: + raise + + def test_password(self, password): + """ + Tests ``password`` agains the user password. + + :param unicode password: a clear text password as submited by the user. + :return: ``True`` if :attr:`username` is valid and ``password`` is + correct, ``False`` otherwise. + :rtype: bool """ if self.user: - return self.user + return check_password( + settings.CAS_SQL_PASSWORD_CHECK, + password, + self.user["password"], + settings.CAS_SQL_PASSWORD_CHARSET + ) else: - return {} + return False + + +class LdapAuthUser(DBAuthUser): # pragma: no cover + """ + A ldap authentication class: authenticate user against a ldap database + + :param unicode username: A username, stored in the :attr:`username` + class attribute. Valid value are fetched from the ldap database set with + ``settings.CAS_LDAP_*`` settings parameters. + """ + + _conn = None + + @classmethod + def get_conn(cls): + """Return a connection object to the ldap database""" + conn = cls._conn + if conn is None or conn.closed: + conn = ldap3.Connection( + settings.CAS_LDAP_SERVER, + settings.CAS_LDAP_USER, + settings.CAS_LDAP_PASSWORD, + auto_bind=True + ) + cls._conn = conn + return conn + + def __init__(self, username): + if not ldap3: + raise RuntimeError("Please install ldap3 before using the LdapAuthUser backend") + # in case we got deconnected from the database, retry to connect 2 times + for retry_nb in range(3): + try: + conn = self.get_conn() + if conn.search( + settings.CAS_LDAP_BASE_DN, + settings.CAS_LDAP_USER_QUERY % ldap3.utils.conv.escape_bytes(username), + attributes=ldap3.ALL_ATTRIBUTES + ) and len(conn.entries) == 1: + user = conn.entries[0].entry_get_attributes_dict() + if user.get(settings.CAS_LDAP_USERNAME_ATTR): + self.user = user + super(LdapAuthUser, self).__init__(user[settings.CAS_LDAP_USERNAME_ATTR][0]) + else: + super(LdapAuthUser, self).__init__(username) + else: + super(LdapAuthUser, self).__init__(username) + break + except ldap3.LDAPCommunicationError: + if retry_nb == 2: + raise + + def test_password(self, password): + """ + Tests ``password`` agains the user password. + + :param unicode password: a clear text password as submited by the user. + :return: ``True`` if :attr:`username` is valid and ``password`` is + correct, ``False`` otherwise. + :rtype: bool + """ + if self.user and self.user.get(settings.CAS_LDAP_PASSWORD_ATTR): + return check_password( + settings.CAS_LDAP_PASSWORD_CHECK, + password, + self.user[settings.CAS_LDAP_PASSWORD_ATTR][0], + settings.CAS_LDAP_PASSWORD_CHARSET + ) + else: + return False class DjangoAuthUser(AuthUser): # pragma: no cover """ - A django auth class: authenticate user agains django internal users + A django auth class: authenticate user against django internal users :param unicode username: A username, stored in the :attr:`username` class attribute. Valid value are usernames of django internal users. diff --git a/cas_server/cas.py b/cas_server/cas.py index 2c5178e..06ce8d2 100644 --- a/cas_server/cas.py +++ b/cas_server/cas.py @@ -134,6 +134,14 @@ class CASClientBase(object): raise CASError(errors[0].attrib['code'], errors[0].text) raise CASError("Bad http code %s" % response.code) + @staticmethod + def get_page_charset(page, default="utf-8"): + content_type = page.info().get('Content-type') + if content_type and "charset=" in content_type: + return content_type.split("charset=")[-1] + else: + return default + class CASClientV1(CASClientBase, ReturnUnicode): """CAS Client Version 1""" @@ -146,17 +154,15 @@ class CASClientV1(CASClientBase, ReturnUnicode): Returns username on success and None on failure. """ params = [('ticket', ticket), ('service', self.service_url)] + if self.renew: + params.append(('renew', 'true')) url = (urllib_parse.urljoin(self.server_url, 'validate') + '?' + urllib_parse.urlencode(params)) page = urllib_request.urlopen(url) try: verified = page.readline().strip() if verified == b'yes': - content_type = page.info().get('Content-type') - if "charset=" in content_type: - charset = content_type.split("charset=")[-1] - else: - charset = "ascii" + charset = self.get_page_charset(page, default="ascii") user = self.u(page.readline().strip(), charset) return user, None, None else: @@ -183,17 +189,15 @@ class CASClientV2(CASClientBase, ReturnUnicode): def get_verification_response(self, ticket): params = [('ticket', ticket), ('service', self.service_url)] + if self.renew: + params.append(('renew', 'true')) if self.proxy_callback: params.append(('pgtUrl', self.proxy_callback)) base_url = urllib_parse.urljoin(self.server_url, self.url_suffix) url = base_url + '?' + urllib_parse.urlencode(params) page = urllib_request.urlopen(url) try: - content_type = page.info().get('Content-type') - if "charset=" in content_type: - charset = content_type.split("charset=")[-1] - else: - charset = "ascii" + charset = self.get_page_charset(page) return (page.read(), charset) finally: page.close() @@ -306,11 +310,7 @@ class CASClientWithSAMLV1(CASClientV2, SingleLogoutMixin): from elementtree import ElementTree page = self.fetch_saml_validation(ticket) - content_type = page.info().get('Content-type') - if "charset=" in content_type: - charset = content_type.split("charset=")[-1] - else: - charset = "ascii" + charset = self.get_page_charset(page) try: user = None diff --git a/cas_server/default_settings.py b/cas_server/default_settings.py index c7b2b12..bfa6a54 100644 --- a/cas_server/default_settings.py +++ b/cas_server/default_settings.py @@ -18,6 +18,8 @@ from importlib import import_module #: URL to the logo showed in the up left corner on the default templates. CAS_LOGO_URL = static("cas_server/logo.png") +#: URL to the favicon (shortcut icon) used by the default templates. Default is a key icon. +CAS_FAVICON_URL = static("cas_server/favicon.ico") #: URLs to css and javascript external components. CAS_COMPONENT_URLS = { "bootstrap3_css": "//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css", @@ -110,12 +112,39 @@ CAS_SQL_PASSWORD = '' CAS_SQL_DBNAME = '' #: Database charset. CAS_SQL_DBCHARSET = 'utf8' + #: The query performed upon user authentication. -CAS_SQL_USER_QUERY = 'SELECT user AS usersame, pass AS password, users.* FROM users WHERE user = %s' +CAS_SQL_USER_QUERY = 'SELECT user AS username, pass AS password, users.* FROM users WHERE user = %s' #: The method used to check the user password. Must be one of ``"crypt"``, ``"ldap"``, #: ``"hex_md5"``, ``"hex_sha1"``, ``"hex_sha224"``, ``"hex_sha256"``, ``"hex_sha384"``, #: ``"hex_sha512"``, ``"plain"``. -CAS_SQL_PASSWORD_CHECK = 'crypt' # crypt or plain +CAS_SQL_PASSWORD_CHECK = 'crypt' +#: charset the SQL users passwords was hash with +CAS_SQL_PASSWORD_CHARSET = "utf-8" + + +#: Address of the LDAP server +CAS_LDAP_SERVER = 'localhost' +#: LDAP user bind address, for example ``"cn=admin,dc=crans,dc=org"`` for connecting to the LDAP +#: server. +CAS_LDAP_USER = None +#: LDAP connection password +CAS_LDAP_PASSWORD = None +#: LDAP seach base DN, for example ``"ou=data,dc=crans,dc=org"``. +CAS_LDAP_BASE_DN = None +#: LDAP search filter for searching user by username. User inputed usernames are escaped using +#: :func:`ldap3.utils.conv.escape_bytes`. +CAS_LDAP_USER_QUERY = "(uid=%s)" +#: LDAP attribute used for users usernames +CAS_LDAP_USERNAME_ATTR = "uid" +#: LDAP attribute used for users passwords +CAS_LDAP_PASSWORD_ATTR = "userPassword" +#: The method used to check the user password. Must be one of ``"crypt"``, ``"ldap"``, +#: ``"hex_md5"``, ``"hex_sha1"``, ``"hex_sha224"``, ``"hex_sha256"``, ``"hex_sha384"``, +#: ``"hex_sha512"``, ``"plain"``. +CAS_LDAP_PASSWORD_CHECK = "ldap" +#: charset the LDAP users passwords was hash with +CAS_LDAP_PASSWORD_CHARSET = "utf-8" #: Username of the test user. @@ -140,6 +169,15 @@ CAS_FEDERATE = False #: Time after witch the cookie use for “remember my identity provider” expire (one week). CAS_FEDERATE_REMEMBER_TIMEOUT = 604800 +#: A :class:`bool` for diplaying a warning on html pages then a new version of the application +#: is avaible. Once closed by a user, it is not displayed to this user until the next new version. +CAS_NEW_VERSION_HTML_WARNING = True +#: A :class:`bool` for sending emails to ``settings.ADMINS`` when a new version is available. +CAS_NEW_VERSION_EMAIL_WARNING = True +#: URL to the pypi json of the application. Used to retreive the version number of the last version. +#: You should not change it. +CAS_NEW_VERSION_JSON_URL = "https://pypi.python.org/pypi/django-cas-server/json" + GLOBALS = globals().copy() for name, default_value in GLOBALS.items(): # get the current setting value, falling back to default_value diff --git a/cas_server/federate.py b/cas_server/federate.py index 2cfd90e..d977771 100644 --- a/cas_server/federate.py +++ b/cas_server/federate.py @@ -42,13 +42,13 @@ class CASFederateValidateUser(object): #: the identity provider provider = None - def __init__(self, provider, service_url): + def __init__(self, provider, service_url, renew=False): self.provider = provider self.client = CASClient( service_url=service_url, version=provider.cas_protocol_version, server_url=provider.server_url, - renew=False, + renew=renew, ) def get_login_url(self): diff --git a/cas_server/forms.py b/cas_server/forms.py index 03c7515..54afb7f 100644 --- a/cas_server/forms.py +++ b/cas_server/forms.py @@ -19,49 +19,56 @@ import cas_server.models as models class BootsrapForm(forms.Form): - """Form base class to use boostrap then rendering the form fields""" + """ + Bases: :class:`django.forms.Form` + + Form base class to use boostrap then rendering the form fields + """ def __init__(self, *args, **kwargs): super(BootsrapForm, self).__init__(*args, **kwargs) - for (name, field) in self.fields.items(): + for field in self.fields.values(): # Only tweak the fiel if it will be displayed if not isinstance(field.widget, forms.HiddenInput): - # tell to display the field (used in form.html) - self[name].display = True attrs = {} - if isinstance(field.widget, forms.CheckboxInput): - self[name].checkbox = True - else: + if not isinstance(field.widget, forms.CheckboxInput): attrs['class'] = "form-control" - if field.label: + if field.label: # pragma: no branch (currently all field are hidden or labeled) attrs["placeholder"] = field.label if field.required: attrs["required"] = "required" field.widget.attrs.update(attrs) -class WarnForm(BootsrapForm): +class BaseLogin(BootsrapForm): """ - Bases: :class:`django.forms.Form` + Bases: :class:`BootsrapForm` - Form used on warn page before emiting a ticket + Base form with all field possibly hidden on the login pages """ - #: The service url for which the user want a ticket service = forms.CharField(widget=forms.HiddenInput(), required=False) + #: A valid LoginTicket to prevent POST replay + lt = forms.CharField(widget=forms.HiddenInput(), required=False) #: Is the service asking the authentication renewal ? renew = forms.BooleanField(widget=forms.HiddenInput(), required=False) #: Url to redirect to if the authentication fail (user not authenticated or bad service) gateway = forms.CharField(widget=forms.HiddenInput(), required=False) method = forms.CharField(widget=forms.HiddenInput(), required=False) + + +class WarnForm(BaseLogin): + """ + Bases: :class:`BaseLogin` + + Form used on warn page before emiting a ticket + """ #: ``True`` if the user has been warned of the ticket emission warned = forms.BooleanField(widget=forms.HiddenInput(), required=False) - #: A valid LoginTicket to prevent POST replay - lt = forms.CharField(widget=forms.HiddenInput(), required=False) -class FederateSelect(BootsrapForm): +class FederateSelect(BaseLogin): """ - Bases: :class:`django.forms.Form` + Bases: :class:`BaseLogin` Form used on the login page when ``settings.CAS_FEDERATE`` is ``True`` allowing the user to choose an identity provider. @@ -76,39 +83,30 @@ class FederateSelect(BootsrapForm): to_field_name="suffix", label=_('Identity provider'), ) - #: The service url for which the user want a ticket - service = forms.CharField(label=_('service'), widget=forms.HiddenInput(), required=False) - method = forms.CharField(widget=forms.HiddenInput(), required=False) + #: A checkbox to ask to be warn before emiting a ticket for another service + warn = forms.BooleanField( + label=_('Warn me before logging me into other sites.'), + required=False + ) #: A checkbox to remember the user choices of :attr:`provider` remember = forms.BooleanField(label=_('Remember the identity provider'), required=False) - #: A checkbox to ask to be warn before emiting a ticket for another service - warn = forms.BooleanField(label=_('warn'), required=False) - #: Is the service asking the authentication renewal ? - renew = forms.BooleanField(widget=forms.HiddenInput(), required=False) -class UserCredential(BootsrapForm): +class UserCredential(BaseLogin): """ - Bases: :class:`django.forms.Form` + Bases: :class:`BaseLogin` Form used on the login page to retrive user credentials """ #: The user username - username = forms.CharField(label=_('login')) - #: The service url for which the user want a ticket - service = forms.CharField(label=_('service'), widget=forms.HiddenInput(), required=False) + username = forms.CharField(label=_('username')) #: The user password password = forms.CharField(label=_('password'), widget=forms.PasswordInput) - #: A valid LoginTicket to prevent POST replay - lt = forms.CharField(widget=forms.HiddenInput(), required=False) - method = forms.CharField(widget=forms.HiddenInput(), required=False) #: A checkbox to ask to be warn before emiting a ticket for another service - warn = forms.BooleanField(label=_('warn'), required=False) - #: Is the service asking the authentication renewal ? - renew = forms.BooleanField(widget=forms.HiddenInput(), required=False) - - def __init__(self, *args, **kwargs): - super(UserCredential, self).__init__(*args, **kwargs) + warn = forms.BooleanField( + label=_('Warn me before logging me into other sites.'), + required=False + ) def clean(self): """ @@ -124,7 +122,9 @@ class UserCredential(BootsrapForm): if auth.test_password(cleaned_data.get("password")): cleaned_data["username"] = auth.username else: - raise forms.ValidationError(_(u"Bad user")) + raise forms.ValidationError( + _(u"The credentials you provided cannot be determined to be authentic.") + ) return cleaned_data @@ -148,21 +148,13 @@ class FederateUserCredential(UserCredential): This stub authentication form, allow to implement the federated mode with very few modificatons to the :class:`LoginView` view. """ - #: the user username with the ``@`` component - username = forms.CharField(widget=forms.HiddenInput()) - #: The service url for which the user want a ticket - service = forms.CharField(widget=forms.HiddenInput(), required=False) - #: The ``ticket`` used to authenticate the user against a provider - password = forms.CharField(widget=forms.HiddenInput()) - #: alias of :attr:`password` - ticket = forms.CharField(widget=forms.HiddenInput()) - #: A valid LoginTicket to prevent POST replay - lt = forms.CharField(widget=forms.HiddenInput(), required=False) - method = forms.CharField(widget=forms.HiddenInput(), required=False) - #: Has the user asked to be warn before emiting a ticket for another service - warn = forms.BooleanField(widget=forms.HiddenInput(), required=False) - #: Is the service asking the authentication renewal ? - renew = forms.BooleanField(widget=forms.HiddenInput(), required=False) + + def __init__(self, *args, **kwargs): + super(FederateUserCredential, self).__init__(*args, **kwargs) + # All fields are hidden and auto filled by the /login view logic + for name, field in self.fields.items(): + field.widget = forms.HiddenInput() + self[name].display = False def clean(self): """ diff --git a/cas_server/locale/en/LC_MESSAGES/django.mo b/cas_server/locale/en/LC_MESSAGES/django.mo deleted file mode 100644 index 8a9dda4..0000000 Binary files a/cas_server/locale/en/LC_MESSAGES/django.mo and /dev/null differ diff --git a/cas_server/locale/en/LC_MESSAGES/django.po b/cas_server/locale/en/LC_MESSAGES/django.po deleted file mode 100644 index 6d4de34..0000000 --- a/cas_server/locale/en/LC_MESSAGES/django.po +++ /dev/null @@ -1,371 +0,0 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# FIRST AUTHOR , YEAR. -# -msgid "" -msgstr "" -"Project-Id-Version: cas_server\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2016-07-04 17:36+0200\n" -"PO-Revision-Date: 2016-07-04 17:39+0200\n" -"Last-Translator: Valentin Samir \n" -"Language-Team: django \n" -"Language: en\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"X-Generator: Poedit 1.8.8\n" - -#: apps.py:19 templates/cas_server/base.html:3 -#: templates/cas_server/base.html:20 -msgid "Central Authentication Service" -msgstr "Central Authentication Service" - -#: forms.py:43 -msgid "Identity provider" -msgstr "Identity provider" - -#: forms.py:45 forms.py:55 forms.py:106 -msgid "service" -msgstr "" - -#: forms.py:47 -msgid "Remember the identity provider" -msgstr "Remember the identity provider" - -#: forms.py:48 forms.py:59 -msgid "warn" -msgstr " Warn me before logging me into other sites." - -#: forms.py:54 -msgid "login" -msgstr "username" - -#: forms.py:56 -msgid "password" -msgstr "password" - -#: forms.py:71 -msgid "Bad user" -msgstr "The credentials you provided cannot be determined to be authentic." - -#: forms.py:96 -msgid "User not found in the temporary database, please try to reconnect" -msgstr "" - -#: management/commands/cas_clean_federate.py:20 -msgid "Clean old federated users" -msgstr "Clean old federated users" - -#: management/commands/cas_clean_sessions.py:22 -msgid "Clean deleted sessions" -msgstr "Clean deleted sessions" - -#: management/commands/cas_clean_tickets.py:22 -msgid "Clean old trickets" -msgstr "Clean old trickets" - -#: models.py:42 -msgid "identity provider" -msgstr "identity provider" - -#: models.py:43 -msgid "identity providers" -msgstr "identity providers" - -#: models.py:47 -msgid "suffix" -msgstr "" - -#: models.py:48 -msgid "" -"Suffix append to backend CAS returner username: `returned_username`@`suffix`" -msgstr "" - -#: models.py:50 -msgid "server url" -msgstr "" - -#: models.py:59 -msgid "CAS protocol version" -msgstr "" - -#: models.py:60 -msgid "" -"Version of the CAS protocol to use when sending requests the the backend CAS" -msgstr "" - -#: models.py:65 -msgid "verbose name" -msgstr "" - -#: models.py:66 -msgid "Name for this identity provider displayed on the login page" -msgstr "" - -#: models.py:70 models.py:317 -msgid "position" -msgstr "position" - -#: models.py:80 -msgid "display" -msgstr "" - -#: models.py:81 -msgid "Display the provider on the login page" -msgstr "" - -#: models.py:164 -msgid "User" -msgstr "" - -#: models.py:165 -msgid "Users" -msgstr "" - -#: models.py:234 -#, python-format -msgid "Error during service logout %s" -msgstr "Error during service logout %s" - -#: models.py:312 -msgid "Service pattern" -msgstr "Service pattern" - -#: models.py:313 -msgid "Services patterns" -msgstr "" - -#: models.py:318 -msgid "service patterns are sorted using the position attribute" -msgstr "" - -#: models.py:325 models.py:449 -msgid "name" -msgstr "name" - -#: models.py:326 -msgid "A name for the service" -msgstr "A name for the service" - -#: models.py:331 models.py:478 models.py:497 -msgid "pattern" -msgstr "pattern" - -#: models.py:333 -msgid "" -"A regular expression matching services. Will usually looks like '^https://" -"some\\.server\\.com/path/.*$'.As it is a regular expression, special " -"character must be escaped with a '\\'." -msgstr "" -"A regular expression matching services. Will usually looks like '^https://" -"some\\.server\\.com/path/.*$'.As it is a regular expression, special " -"character must be escaped with a '\\'." - -#: models.py:342 -msgid "user field" -msgstr "" - -#: models.py:343 -msgid "Name of the attribut to transmit as username, empty = login" -msgstr "Name of the attribut to transmit as username, empty = login" - -#: models.py:347 -msgid "restrict username" -msgstr "" - -#: models.py:348 -msgid "Limit username allowed to connect to the list provided bellow" -msgstr "Limit username allowed to connect to the list provided bellow" - -#: models.py:352 -msgid "proxy" -msgstr "proxy" - -#: models.py:353 -msgid "Proxy tickets can be delivered to the service" -msgstr "Proxy tickets can be delivered to the service" - -#: models.py:357 -msgid "proxy callback" -msgstr "proxy callback" - -#: models.py:358 -msgid "can be used as a proxy callback to deliver PGT" -msgstr "can be used as a proxy callback to deliver PGT" - -#: models.py:362 -msgid "single log out" -msgstr "" - -#: models.py:363 -msgid "Enable SLO for the service" -msgstr "Enable SLO for the service" - -#: models.py:370 -msgid "single log out callback" -msgstr "" - -#: models.py:371 -msgid "" -"URL where the SLO request will be POST. empty = service url\n" -"This is usefull for non HTTP proxied services." -msgstr "" - -#: models.py:433 -msgid "username" -msgstr "" - -#: models.py:434 -msgid "username allowed to connect to the service" -msgstr "username allowed to connect to the service" - -#: models.py:450 -msgid "name of an attribut to send to the service, use * for all attributes" -msgstr "name of an attribut to send to the service, use * for all attributes" - -#: models.py:455 models.py:503 -msgid "replace" -msgstr "replace" - -#: models.py:456 -msgid "" -"name under which the attribut will be showto the service. empty = default " -"name of the attribut" -msgstr "" -"name under which the attribut will be showto the service. empty = default " -"name of the attribut" - -#: models.py:473 models.py:492 -msgid "attribut" -msgstr "attribut" - -#: models.py:474 -msgid "Name of the attribut which must verify pattern" -msgstr "Name of the attribut which must verify pattern" - -#: models.py:479 -msgid "a regular expression" -msgstr "a regular expression" - -#: models.py:493 -msgid "Name of the attribut for which the value must be replace" -msgstr "Name of the attribut for which the value must be replace" - -#: models.py:498 -msgid "An regular expression maching whats need to be replaced" -msgstr "An regular expression maching whats need to be replaced" - -#: models.py:504 -msgid "replace expression, groups are capture by \\1, \\2 …" -msgstr "replace expression, groups are capture by \\1, \\2 …" - -#: templates/cas_server/logged.html:6 -msgid "Logged" -msgstr "" -"

Log In Successful

You have successfully logged into the Central " -"Authentication Service.
For security reasons, please Log Out and Exit " -"your web browser when you are done accessing services that require " -"authentication!" - -#: templates/cas_server/logged.html:10 -msgid "Log me out from all my sessions" -msgstr "Log me out from all my sessions" - -#: templates/cas_server/logged.html:13 -msgid "Logout" -msgstr "Logout" - -#: templates/cas_server/login.html:8 -msgid "Please log in" -msgstr "Please log in" - -#: templates/cas_server/login.html:13 -msgid "Login" -msgstr "Login" - -#: templates/cas_server/warn.html:10 -msgid "Connect to the service" -msgstr "Connect to the service" - -#: views.py:152 -msgid "" -"

Logout successful

You have successfully logged out from the Central " -"Authentication Service. For security reasons, exit your web browser." -msgstr "" -"

Logout successful

You have successfully logged out from the Central " -"Authentication Service. For security reasons, exit your web browser." - -#: views.py:158 -#, python-format -msgid "" -"

Logout successful

You have successfully logged out from %s sessions " -"of the Central Authentication Service. For security reasons, exit your web " -"browser." -msgstr "" -"

Logout successful

You have successfully logged out from %s sessions " -"of the Central Authentication Service. For security reasons, exit your web " -"browser." - -#: views.py:165 -msgid "" -"

Logout successful

You were already logged out from the Central " -"Authentication Service. For security reasons, exit your web browser." -msgstr "" -"

Logout successful

You were already logged out from the Central " -"Authentication Service. For security reasons, exit your web browser." - -#: views.py:349 -msgid "Invalid login ticket" -msgstr "Invalid login ticket, please retry to login" - -#: views.py:470 -#, python-format -msgid "Authentication has been required by service %(name)s (%(url)s)" -msgstr "Authentication has been required by service %(name)s (%(url)s)" - -#: views.py:508 -#, python-format -msgid "Service %(url)s non allowed." -msgstr "Service %(url)s non allowed." - -#: views.py:515 -msgid "Username non allowed" -msgstr "Username non allowed" - -#: views.py:522 -msgid "User charateristics non allowed" -msgstr "User charateristics non allowed" - -#: views.py:529 -#, python-format -msgid "The attribut %(field)s is needed to use that service" -msgstr "The attribut %(field)s is needed to use that service" - -#: views.py:599 -#, python-format -msgid "Authentication renewal required by service %(name)s (%(url)s)." -msgstr "Authentication renewal required by service %(name)s (%(url)s)." - -#: views.py:606 -#, python-format -msgid "Authentication required by service %(name)s (%(url)s)." -msgstr "Authentication required by service %(name)s (%(url)s)." - -#: views.py:613 -#, python-format -msgid "Service %s non allowed" -msgstr "Service %s non allowed" - -#~ msgid "" -#~ "Error during service logout %(service)s:\n" -#~ "%(error)s" -#~ msgstr "" -#~ "Error during service logout %(service)s:\n" -#~ "%(error)s" - -#~ msgid "Successfully logout" -#~ msgstr "" -#~ "

Logout successful

You have successfully logged out of the Central " -#~ "Authentication Service.
For security reasons, exit your web browser." diff --git a/cas_server/locale/fr/LC_MESSAGES/django.mo b/cas_server/locale/fr/LC_MESSAGES/django.mo index cd54e7a..d0f80ed 100644 Binary files a/cas_server/locale/fr/LC_MESSAGES/django.mo and b/cas_server/locale/fr/LC_MESSAGES/django.mo differ diff --git a/cas_server/locale/fr/LC_MESSAGES/django.po b/cas_server/locale/fr/LC_MESSAGES/django.po index 8a7e606..bdcc3a7 100644 --- a/cas_server/locale/fr/LC_MESSAGES/django.po +++ b/cas_server/locale/fr/LC_MESSAGES/django.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: cas_server\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2016-07-04 17:36+0200\n" -"PO-Revision-Date: 2016-07-04 17:37+0200\n" +"POT-Creation-Date: 2016-08-01 12:01+0200\n" +"PO-Revision-Date: 2016-08-01 12:01+0200\n" "Last-Translator: Valentin Samir \n" "Language-Team: django \n" "Language: fr\n" @@ -18,45 +18,45 @@ msgstr "" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "X-Generator: Poedit 1.8.8\n" -#: apps.py:19 templates/cas_server/base.html:3 -#: templates/cas_server/base.html:20 +#: apps.py:25 templates/cas_server/base.html:9 +#: templates/cas_server/base.html:27 msgid "Central Authentication Service" msgstr "Service Central d'Authentification" -#: forms.py:43 +#: forms.py:88 msgid "Identity provider" msgstr "fournisseur d'identité" -#: forms.py:45 forms.py:55 forms.py:106 -msgid "service" -msgstr "service" +#: forms.py:92 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:47 +#: forms.py:96 msgid "Remember the identity provider" msgstr "Se souvenir du fournisseur d'identité" -#: forms.py:48 forms.py:59 -msgid "warn" -msgstr "Prévenez-moi avant d'accéder à d'autres services." +#: forms.py:106 models.py:600 +msgid "username" +msgstr "nom d'utilisateur" -#: forms.py:54 -msgid "login" -msgstr "Identifiant" - -#: forms.py:56 +#: forms.py:108 msgid "password" msgstr "mot de passe" -#: forms.py:71 -msgid "Bad user" +#: forms.py:130 +msgid "The credentials you provided cannot be determined to be authentic." msgstr "Les informations transmises n'ont pas permis de vous authentifier." -#: forms.py:96 +#: forms.py:182 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:196 +msgid "service" +msgstr "service" + #: management/commands/cas_clean_federate.py:20 msgid "Clean old federated users" msgstr "Nettoyer les anciens utilisateurs fédéré" @@ -69,98 +69,99 @@ msgstr "Nettoyer les sessions supprimées" msgid "Clean old trickets" msgstr "Nettoyer les vieux tickets" -#: models.py:42 +#: models.py:46 msgid "identity provider" msgstr "fournisseur d'identité" -#: models.py:43 +#: models.py:47 msgid "identity providers" msgstr "fournisseurs d'identités" -#: models.py:47 +#: models.py:53 msgid "suffix" msgstr "suffixe" -#: models.py:48 +#: models.py:55 msgid "" -"Suffix append to backend CAS returner username: `returned_username`@`suffix`" +"Suffix append to backend CAS returned username: ``returned_username`` @ " +"``suffix``." msgstr "" "Suffixe ajouté au nom d'utilisateur retourné par le CAS du fournisseur " -"d'identité : `nom retourné`@`suffixe`" +"d'identité : `nom retourné`@`suffixe`." -#: models.py:50 +#: models.py:62 msgid "server url" msgstr "url du serveur" -#: models.py:59 +#: models.py:72 msgid "CAS protocol version" msgstr "Version du protocole CAS" -#: models.py:60 +#: models.py:74 msgid "" -"Version of the CAS protocol to use when sending requests the the backend CAS" +"Version of the CAS protocol to use when sending requests the the backend CAS." msgstr "" "Version du protocole CAS à utiliser lorsque l'on envoie des requête au CAS " -"du fournisseur d'identité" +"du fournisseur d'identité." -#: models.py:65 +#: models.py:81 msgid "verbose name" msgstr "Nom du fournisseur" -#: models.py:66 -msgid "Name for this identity provider displayed on the login page" -msgstr "Nom affiché pour ce fournisseur d'identité sur la page de connexion" +#: models.py:82 +msgid "Name for this identity provider displayed on the login page." +msgstr "Nom affiché pour ce fournisseur d'identité sur la page de connexion." -#: models.py:70 models.py:317 +#: models.py:88 models.py:446 msgid "position" msgstr "position" -#: models.py:80 +#: models.py:102 msgid "display" msgstr "afficher" -#: models.py:81 -msgid "Display the provider on the login page" -msgstr "Afficher le fournisseur d'identité sur la page de connexion" +#: models.py:103 +msgid "Display the provider on the login page." +msgstr "Afficher le fournisseur d'identité sur la page de connexion." -#: models.py:164 +#: models.py:233 msgid "User" msgstr "Utilisateur" -#: models.py:165 +#: models.py:234 msgid "Users" msgstr "Utilisateurs" -#: models.py:234 +#: models.py:320 #, python-format msgid "Error during service logout %s" msgstr "Une erreur est survenue durant la déconnexion du service %s" -#: models.py:312 +#: models.py:440 msgid "Service pattern" msgstr "Motif de service" -#: models.py:313 +#: models.py:441 msgid "Services patterns" msgstr "Motifs de services" -#: models.py:318 +#: models.py:447 msgid "service patterns are sorted using the position attribute" msgstr "Les motifs de service sont trié selon l'attribut position" -#: models.py:325 models.py:449 +#: models.py:455 models.py:626 msgid "name" msgstr "nom" -#: models.py:326 +#: models.py:456 msgid "A name for the service" msgstr "Un nom pour le service" -#: models.py:331 models.py:478 models.py:497 +#: models.py:464 models.py:669 models.py:698 msgid "pattern" msgstr "motif" -#: models.py:333 +#: models.py:466 msgid "" "A regular expression matching services. Will usually looks like '^https://" "some\\.server\\.com/path/.*$'.As it is a regular expression, special " @@ -171,55 +172,55 @@ msgstr "" "expression rationnelle, les caractères spéciaux doivent être échappés avec " "un '\\'." -#: models.py:342 +#: models.py:476 msgid "user field" msgstr "champ utilisateur" -#: models.py:343 -msgid "Name of the attribut to transmit as username, empty = login" +#: models.py:477 +msgid "Name of the attribute to transmit as username, empty = login" msgstr "" "Nom de l'attribut devant être transmis comme nom d'utilisateur au service. " -"vide = nom de connection" +"vide = nom de connexion" -#: models.py:347 +#: models.py:482 msgid "restrict username" msgstr "limiter les noms d'utilisateurs" -#: models.py:348 +#: models.py:483 msgid "Limit username allowed to connect to the list provided bellow" msgstr "" "Limiter les noms d'utilisateurs autorisé à se connecter à la liste fournie " "ci-dessous" -#: models.py:352 +#: models.py:488 msgid "proxy" msgstr "proxy" -#: models.py:353 +#: models.py:489 msgid "Proxy tickets can be delivered to the service" msgstr "des proxy tickets peuvent être délivrés au service" -#: models.py:357 +#: models.py:495 msgid "proxy callback" msgstr "" -#: models.py:358 +#: models.py:496 msgid "can be used as a proxy callback to deliver PGT" msgstr "peut être utilisé comme un callback pour recevoir un PGT" -#: models.py:362 +#: models.py:503 msgid "single log out" msgstr "" -#: models.py:363 +#: models.py:504 msgid "Enable SLO for the service" msgstr "Active le SLO pour le service" -#: models.py:370 +#: models.py:512 msgid "single log out callback" msgstr "" -#: models.py:371 +#: models.py:513 msgid "" "URL where the SLO request will be POST. empty = service url\n" "This is usefull for non HTTP proxied services." @@ -228,83 +229,98 @@ msgstr "" "service\n" "Ceci n'est utilise que pour des services non HTTP proxifiés" -#: models.py:433 -msgid "username" -msgstr "nom d'utilisateur" - -#: models.py:434 +#: models.py:601 msgid "username allowed to connect to the service" msgstr "noms d'utilisateurs autorisé à se connecter au service" -#: models.py:450 -msgid "name of an attribut to send to the service, use * for all attributes" +#: models.py:627 +msgid "name of an attribute to send to the service, use * for all attributes" msgstr "" "nom d'un attribut a envoyer au service, utiliser * pour tous les attributs" -#: models.py:455 models.py:503 +#: models.py:634 models.py:705 msgid "replace" msgstr "remplacement" -#: models.py:456 +#: models.py:635 msgid "" -"name under which the attribut will be showto the service. empty = default " +"name under which the attribute will be showto the service. empty = default " "name of the attribut" msgstr "" "nom sous lequel l'attribut sera rendu visible au service. vide = inchangé" -#: models.py:473 models.py:492 -msgid "attribut" +#: models.py:662 models.py:692 +msgid "attribute" msgstr "attribut" -#: models.py:474 -msgid "Name of the attribut which must verify pattern" +#: models.py:663 +msgid "Name of the attribute which must verify pattern" msgstr "Nom de l'attribut devant vérifier un motif" -#: models.py:479 +#: models.py:670 msgid "a regular expression" msgstr "une expression régulière" -#: models.py:493 -msgid "Name of the attribut for which the value must be replace" -msgstr "nom de l'attribue pour lequel la valeur doit être remplacé" +#: models.py:693 +msgid "Name of the attribute for which the value must be replace" +msgstr "nom de l'attribut pour lequel la valeur doit être remplacé" -#: models.py:498 +#: models.py:699 msgid "An regular expression maching whats need to be replaced" msgstr "une expression régulière reconnaissant ce qui doit être remplacé" -#: models.py:504 +#: models.py:706 msgid "replace expression, groups are capture by \\1, \\2 …" msgstr "expression de remplacement, les groupe sont capturé par \\1, \\2" -#: templates/cas_server/logged.html:6 -msgid "Logged" +#: templates/cas_server/base.html:38 +#, python-format +msgid "" +"A new version of the application is available. This instance runs " +"%(VERSION)s and the last version is %(LAST_VERSION)s. Please consider " +"upgrading." msgstr "" -"

Connexion réussie

Vous vous êtes authentifié(e) auprès du Service " -"Central d'Authentification.
Pour des raisons de sécurité, veuillez vous " -"déconnecter et fermer votre navigateur lorsque vous avez fini d'accéder aux " -"services authentifiés." +"Une nouvelle version de l'application est disponible. Cette instance utilise " +"la version %(VERSION)s et la dernière version est %(LAST_VERSION)s. Merci de " +"vous mettre a jour." -#: templates/cas_server/logged.html:10 +#: templates/cas_server/logged.html:4 +msgid "" +"

Log In Successful

You have successfully logged into the Central " +"Authentication Service.
For security reasons, please Log Out and Exit " +"your web browser when you are done accessing services that require " +"authentication!" +msgstr "" +"

Déconnexion réussie

Vous vous êtes déconnecté(e) du Service Central " +"d'Authentification. Pour des raisons de sécurité, veuillez fermer votre " +"navigateur après avoir fini d'accéder a des services demandant une " +"authentification !" + +#: templates/cas_server/logged.html:8 msgid "Log me out from all my sessions" msgstr "Me déconnecter de toutes mes sessions" -#: templates/cas_server/logged.html:13 +#: templates/cas_server/logged.html:14 +msgid "Forget the identity provider" +msgstr "Oublier le fournisseur d'identité" + +#: templates/cas_server/logged.html:18 msgid "Logout" msgstr "Se déconnecter" -#: templates/cas_server/login.html:8 +#: templates/cas_server/login.html:6 msgid "Please log in" msgstr "Merci de se connecter" -#: templates/cas_server/login.html:13 +#: templates/cas_server/login.html:14 msgid "Login" msgstr "Connexion" -#: templates/cas_server/warn.html:10 +#: templates/cas_server/warn.html:9 msgid "Connect to the service" msgstr "Se connecter au service" -#: views.py:152 +#: views.py:168 msgid "" "

Logout successful

You have successfully logged out from the Central " "Authentication Service. For security reasons, exit your web browser." @@ -313,7 +329,7 @@ msgstr "" "d'Authentification. Pour des raisons de sécurité, veuillez fermer votre " "navigateur." -#: views.py:158 +#: views.py:174 #, python-format msgid "" "

Logout successful

You have successfully logged out from %s sessions " @@ -324,7 +340,7 @@ msgstr "" "Service Central d'Authentification. Pour des raisons de sécurité, veuillez " "fermer votre navigateur." -#: views.py:165 +#: views.py:181 msgid "" "

Logout successful

You were already logged out from the Central " "Authentication Service. For security reasons, exit your web browser." @@ -333,50 +349,75 @@ msgstr "" "d'Authentification. Pour des raisons de sécurité, veuillez fermer votre " "navigateur." -#: views.py:349 -msgid "Invalid login ticket" +#: views.py:361 +#, python-format +msgid "" +"Invalid response from your identity provider CAS upon ticket %(ticket)s " +"validation: %(error)r" +msgstr "" +"Réponse invalide du CAS du fournisseur d'identité lors de la validation du " +"ticket %(ticket)s: %(error)r" + +#: views.py:483 +msgid "Invalid login ticket, please retry to login" msgstr "Ticket de connexion invalide, merci de réessayé de vous connecter" -#: views.py:470 +#: views.py:675 #, python-format msgid "Authentication has been required by service %(name)s (%(url)s)" msgstr "" "Une demande d'authentification a été émise pour le service %(name)s " "(%(url)s)." -#: views.py:508 +#: views.py:713 #, python-format msgid "Service %(url)s non allowed." msgstr "le service %(url)s n'est pas autorisé." -#: views.py:515 +#: views.py:720 msgid "Username non allowed" msgstr "Nom d'utilisateur non authorisé" -#: views.py:522 -msgid "User charateristics non allowed" +#: views.py:727 +msgid "User characteristics non allowed" msgstr "Caractéristique utilisateur non autorisée" -#: views.py:529 +#: views.py:734 #, python-format -msgid "The attribut %(field)s is needed to use that service" +msgid "The attribute %(field)s is needed to use that service" msgstr "L'attribut %(field)s est nécessaire pour se connecter à ce service" -#: views.py:599 +#: views.py:824 #, python-format msgid "Authentication renewal required by service %(name)s (%(url)s)." msgstr "Demande de réauthentification pour le service %(name)s (%(url)s)." -#: views.py:606 +#: views.py:831 #, python-format msgid "Authentication required by service %(name)s (%(url)s)." msgstr "Authentification requise par le service %(name)s (%(url)s)." -#: views.py:613 +#: views.py:838 #, python-format msgid "Service %s non allowed" msgstr "Le service %s n'est pas autorisé" +#~ msgid "Logged" +#~ msgstr "" +#~ "

Connexion réussie

Vous vous êtes authentifié(e) auprès du Service " +#~ "Central d'Authentification.
Pour des raisons de sécurité, veuillez " +#~ "vous déconnecter et fermer votre navigateur lorsque vous avez fini " +#~ "d'accéder aux services authentifiés." + +#~ msgid "warn" +#~ msgstr "Prévenez-moi avant d'accéder à d'autres services." + +#~ msgid "login" +#~ msgstr "Identifiant" + +#~ msgid "Bad user" +#~ msgstr "Les informations transmises n'ont pas permis de vous authentifier." + #~ msgid "" #~ "Error during service logout %(service)s:\n" #~ "%(error)s" diff --git a/cas_server/management/commands/cas_clean_sessions.py b/cas_server/management/commands/cas_clean_sessions.py index 437bcb5..5de4ebf 100644 --- a/cas_server/management/commands/cas_clean_sessions.py +++ b/cas_server/management/commands/cas_clean_sessions.py @@ -23,3 +23,4 @@ class Command(BaseCommand): def handle(self, *args, **options): models.User.clean_deleted_sessions() + models.NewVersionWarning.send_mails() diff --git a/cas_server/migrations/0008_newversionwarning.py b/cas_server/migrations/0008_newversionwarning.py new file mode 100644 index 0000000..f5e4b19 --- /dev/null +++ b/cas_server/migrations/0008_newversionwarning.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-07-27 21:59 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cas_server', '0007_auto_20160723_2252'), + ] + + operations = [ + migrations.CreateModel( + name='NewVersionWarning', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', models.CharField(max_length=255)), + ], + ), + ] diff --git a/cas_server/models.py b/cas_server/models.py index 6e87d40..dfedb1e 100644 --- a/cas_server/models.py +++ b/cas_server/models.py @@ -18,15 +18,18 @@ from django.contrib import messages from django.utils.translation import ugettext_lazy as _ from django.utils import timezone from django.utils.encoding import python_2_unicode_compatible +from django.core.mail import send_mail import re import sys +import smtplib import logging from datetime import timedelta from concurrent.futures import ThreadPoolExecutor from requests_futures.sessions import FuturesSession import cas_server.utils as utils +from . import VERSION #: logger facility logger = logging.getLogger(__name__) @@ -465,13 +468,13 @@ class ServicePattern(models.Model): "As it is a regular expression, special character must be escaped with a '\\'." ) ) - #: Name of the attribut to transmit as username, if empty the user login is used + #: Name of the attribute to transmit as username, if empty the user login is used user_field = models.CharField( max_length=255, default="", blank=True, verbose_name=_(u"user field"), - help_text=_("Name of the attribut to transmit as username, empty = login") + help_text=_("Name of the attribute to transmit as username, empty = login") ) #: A boolean allowing to limit username allowed to connect to :attr:`usernames`. restrict_users = models.BooleanField( @@ -621,7 +624,7 @@ class ReplaceAttributName(models.Model): name = models.CharField( max_length=255, verbose_name=_(u"name"), - help_text=_(u"name of an attribut to send to the service, use * for all attributes") + help_text=_(u"name of an attribute to send to the service, use * for all attributes") ) #: The name of the attribute to transmit to the service. If empty, the value of :attr:`name` #: is used. @@ -629,7 +632,7 @@ class ReplaceAttributName(models.Model): max_length=255, blank=True, verbose_name=_(u"replace"), - help_text=_(u"name under which the attribut will be show" + help_text=_(u"name under which the attribute will be show" u"to the service. empty = default name of the attribut") ) #: ForeignKey to a :class:`ServicePattern`. :class:`ReplaceAttributName` instances for a @@ -656,8 +659,8 @@ class FilterAttributValue(models.Model): #: The name of a user attribute attribut = models.CharField( max_length=255, - verbose_name=_(u"attribut"), - help_text=_(u"Name of the attribut which must verify pattern") + verbose_name=_(u"attribute"), + help_text=_(u"Name of the attribute which must verify pattern") ) #: A regular expression the attribute :attr:`attribut` value must verify. If :attr:`attribut` #: if a list, only one of the list values needs to match. @@ -686,8 +689,8 @@ class ReplaceAttributValue(models.Model): #: Name the attribute: a key of :attr:`User.attributs` attribut = models.CharField( max_length=255, - verbose_name=_(u"attribut"), - help_text=_(u"Name of the attribut for which the value must be replace") + verbose_name=_(u"attribute"), + help_text=_(u"Name of the attribute for which the value must be replace") ) #: A regular expression matching the part of the attribute value that need to be changed pattern = models.CharField( @@ -1003,3 +1006,60 @@ class Proxy(models.Model): def __str__(self): return self.url + + +class NewVersionWarning(models.Model): + """ + Bases: :class:`django.db.models.Model` + + The last new version available version sent + """ + version = models.CharField(max_length=255) + + @classmethod + def send_mails(cls): + """ + For each new django-cas-server version, if the current instance is not up to date + send one mail to ``settings.ADMINS``. + """ + if settings.CAS_NEW_VERSION_EMAIL_WARNING and settings.ADMINS: + try: + obj = cls.objects.get() + except cls.DoesNotExist: + obj = NewVersionWarning.objects.create(version=VERSION) + LAST_VERSION = utils.last_version() + if LAST_VERSION is not None and LAST_VERSION != obj.version: + if utils.decode_version(VERSION) < utils.decode_version(LAST_VERSION): + try: + send_mail( + ( + '%sA new version of django-cas-server is available' + ) % settings.EMAIL_SUBJECT_PREFIX, + u''' +A new version of the django-cas-server is available. + +Your version: %s +New version: %s + +Upgrade using: + * pip install -U django-cas-server + * fetching the last release on + https://github.com/nitmir/django-cas-server/ or on + https://pypi.python.org/pypi/django-cas-server + +After upgrade, do not forget to run: + * ./manage.py migrate + * ./manage.py collectstatic +and to reload your wsgi server (apache2, uwsgi, gunicord, etc…) + +--\u0020 +django-cas-server +'''.strip() % (VERSION, LAST_VERSION), + settings.SERVER_EMAIL, + ["%s <%s>" % admin for admin in settings.ADMINS], + fail_silently=False, + ) + obj.version = LAST_VERSION + obj.save() + except smtplib.SMTPException as error: # pragma: no cover (should not happen) + logger.error("Unable to send new version mail: %s" % error) diff --git a/cas_server/static/cas_server/alert-version.js b/cas_server/static/cas_server/alert-version.js new file mode 100644 index 0000000..7b4fef5 --- /dev/null +++ b/cas_server/static/cas_server/alert-version.js @@ -0,0 +1,27 @@ +function alert_version(last_version){ + jQuery(function( $ ){ + $("#alert-version").click(function( e ){ + e.preventDefault(); + var date = new Date(); + date.setTime(date.getTime()+(10*365*24*60*60*1000)); + var expires = "; expires="+date.toGMTString(); + document.cookie = "cas-alert-version=" + last_version + expires + "; path=/"; + }); + + var nameEQ="cas-alert-version="; + var ca = document.cookie.split(";"); + var value; + for(var i=0;i < ca.length;i++) { + var c = ca[i]; + while(c.charAt(0) === " "){ + c = c.substring(1,c.length); + } + if(c.indexOf(nameEQ) === 0){ + value = c.substring(nameEQ.length,c.length); + } + } + if(value === last_version){ + $("#alert-version").parent().hide(); + } + }); +} diff --git a/cas_server/static/cas_server/login.css b/cas_server/static/cas_server/styles.css similarity index 100% rename from cas_server/static/cas_server/login.css rename to cas_server/static/cas_server/styles.css diff --git a/cas_server/templates/cas_server/base.html b/cas_server/templates/cas_server/base.html index db61e1b..87b9a6d 100644 --- a/cas_server/templates/cas_server/base.html +++ b/cas_server/templates/cas_server/base.html @@ -14,8 +14,8 @@ - - + {% if settings.CAS_FAVICON_URL %}{% endif %} +
@@ -31,23 +31,28 @@
- {% block ante_messages %}{% endblock %} {% if auto_submit %}
+ {% if settings.CAS_NEW_VERSION_HTML_WARNING and upgrade_available %} + + + {% endif %} diff --git a/cas_server/templates/cas_server/form.html b/cas_server/templates/cas_server/form.html index 5ac1463..f189f6b 100644 --- a/cas_server/templates/cas_server/form.html +++ b/cas_server/templates/cas_server/form.html @@ -1,10 +1,11 @@ +{% load cas_server %} {% for error in form.non_field_errors %}
{{error}}
{% endfor %} -{% for field in form %}{% if field.display %} +{% for field in form %}{% if not field|is_hidden %}
{% spaceless %} - {% if field.checkbox %} + {% if field|is_checkbox %}
{% else %} diff --git a/cas_server/templates/cas_server/logged.html b/cas_server/templates/cas_server/logged.html index f29445b..46e1c9a 100644 --- a/cas_server/templates/cas_server/logged.html +++ b/cas_server/templates/cas_server/logged.html @@ -1,13 +1,20 @@ {% extends "cas_server/base.html" %} {% load i18n %} {% block content %} - + {% endblock %} diff --git a/cas_server/templatetags/__init__.py b/cas_server/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cas_server/templatetags/cas_server.py b/cas_server/templatetags/cas_server.py new file mode 100644 index 0000000..ec2839e --- /dev/null +++ b/cas_server/templatetags/cas_server.py @@ -0,0 +1,14 @@ +from django import template +from django import forms + +register = template.Library() + + +@register.filter(name='is_checkbox') +def is_checkbox(field): + return isinstance(field.field.widget, forms.CheckboxInput) + + +@register.filter(name='is_hidden') +def is_hidden(field): + return isinstance(field.field.widget, forms.HiddenInput) diff --git a/cas_server/tests/settings.py b/cas_server/tests/settings.py index 4e17ceb..e332688 100644 --- a/cas_server/tests/settings.py +++ b/cas_server/tests/settings.py @@ -51,6 +51,22 @@ MIDDLEWARE_CLASSES = [ 'django.middleware.locale.LocaleMiddleware', ] +TEMPLATES = [ + { + 'APP_DIRS': True, + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages' + ] + } + } +] + ROOT_URLCONF = 'cas_server.tests.urls' # Database @@ -81,3 +97,30 @@ USE_TZ = True # https://docs.djangoproject.com/en/1.9/howto/static-files/ STATIC_URL = '/static/' + +CAS_NEW_VERSION_HTML_WARNING = False +CAS_NEW_VERSION_EMAIL_WARNING = False + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'cas_file': { + 'format': '%(asctime)s %(levelname)s %(message)s' + }, + }, + 'handlers': { + 'cas_stream': { + 'level': 'INFO', + 'class': 'logging.StreamHandler', + 'formatter': 'cas_file', + }, + }, + 'loggers': { + 'cas_server': { + 'handlers': ['cas_stream'], + 'level': 'INFO', + 'propagate': True, + }, + }, +} diff --git a/cas_server/tests/test_federate.py b/cas_server/tests/test_federate.py index 3731dc5..cfcc5b7 100644 --- a/cas_server/tests/test_federate.py +++ b/cas_server/tests/test_federate.py @@ -84,29 +84,31 @@ class FederateAuthLoginLogoutTestCase( params['provider'] = provider.suffix if remember: params['remember'] = 'on' + # just try for one suffix + if suffix == "example.com": + # if renew=False is posted it should be ignored + params["renew"] = False # post the choosed provider response = client.post('/federate', params) # we are redirected to the provider CAS client url self.assertEqual(response.status_code, 302) - if remember: - self.assertEqual(response["Location"], '%s/federate/%s?remember=on' % ( - 'http://testserver' if django.VERSION < (1, 9) else "", - provider.suffix - )) - else: - self.assertEqual(response["Location"], '%s/federate/%s' % ( - 'http://testserver' if django.VERSION < (1, 9) else "", - provider.suffix - )) + self.assertEqual(response["Location"], '%s/federate/%s%s' % ( + 'http://testserver' if django.VERSION < (1, 9) else "", + provider.suffix, + "?remember=on" if remember else "" + )) # let's follow the redirect - response = client.get('/federate/%s' % provider.suffix) + response = client.get( + '/federate/%s%s' % (provider.suffix, "?remember=on" if remember else "") + ) # we are redirected to the provider CAS for authentication self.assertEqual(response.status_code, 302) self.assertEqual( response["Location"], - "%s/login?service=http%%3A%%2F%%2Ftestserver%%2Ffederate%%2F%s" % ( + "%s/login?service=http%%3A%%2F%%2Ftestserver%%2Ffederate%%2F%s%s" % ( provider.server_url, - provider.suffix + provider.suffix, + "%3Fremember%3Don" if remember else "" ) ) # let's generate a ticket @@ -114,7 +116,10 @@ class FederateAuthLoginLogoutTestCase( # we lauch a dummy CAS server that only validate once for the service # http://testserver/federate/example.com with `ticket` tests_utils.DummyCAS.run( - ("http://testserver/federate/%s" % provider.suffix).encode("ascii"), + ("http://testserver/federate/%s%s" % ( + provider.suffix, + "?remember=on" if remember else "" + )).encode("ascii"), ticket.encode("ascii"), settings.CAS_TEST_USER.encode("utf8"), [], @@ -122,7 +127,13 @@ class FederateAuthLoginLogoutTestCase( ) # we normally provide a good ticket and should be redirected to /login as the ticket # get successfully validated again the dummy CAS - response = client.get('/federate/%s' % provider.suffix, {'ticket': ticket}) + response = client.get( + '/federate/%s' % provider.suffix, + {'ticket': ticket, 'remember': 'on' if remember else ''} + ) + if remember: + self.assertIn("remember_provider", client.cookies) + self.assertEqual(client.cookies["remember_provider"].value, provider.suffix) self.assertEqual(response.status_code, 302) self.assertEqual(response["Location"], "%s/login" % ( 'http://testserver' if django.VERSION < (1, 9) else "" @@ -183,7 +194,8 @@ class FederateAuthLoginLogoutTestCase( """ The federated view should redirect to /login if the provider is unknown or not provided, try to fetch a new ticket if the provided ticket validation fail - (network error or bad ticket) + (network error or bad ticket), redirect to /login with a error message if identity + provider CAS return a bad response (invalid XML document) """ good_provider = "example.com" bad_provider = "exemple.fr" @@ -229,6 +241,18 @@ class FederateAuthLoginLogoutTestCase( 'http://testserver' if django.VERSION < (1, 9) else "" )) + # test CAS avaible but return a bad XML doc, should redirect to /login with a error message + # use "example.net" as it is CASv3 + tests_utils.HttpParamsHandler.run(8082) + response = client.get("/federate/%s" % "example.net", {'ticket': utils.gen_st()}) + self.assertEqual(response.status_code, 302) + self.assertEqual(response["Location"], "%s/login" % ( + 'http://testserver' if django.VERSION < (1, 9) else "" + )) + response = client.get("/login") + self.assertEqual(response.status_code, 200) + self.assertIn(b"Invalid response from your identity provider CAS", response.content) + def test_auth_federate_slo(self): """test that SLO receive from backend CAS log out the users""" # get tickets and connected clients @@ -331,6 +355,76 @@ class FederateAuthLoginLogoutTestCase( provider.suffix )) + def test_forget_provider(self): + """Test the logout option to forget remembered provider""" + tickets = self.test_login_post_provider(remember=True) + for (provider, _, client) in tickets: + self.assertIn("remember_provider", client.cookies) + self.assertEqual(client.cookies["remember_provider"].value, provider.suffix) + self.assertNotEqual(client.cookies["remember_provider"]["max-age"], 0) + client.get("/logout?forget_provider=1") + self.assertEqual(client.cookies["remember_provider"]["max-age"], 0) + + def test_renew(self): + """ + Test authentication renewal with federation mode + """ + tickets = self.test_login_post_provider() + for (provider, _, client) in tickets: + # Try to renew authentication(client already authenticated in test_login_post_provider + response = client.get("/login?renew=true") + # we should be redirected to the user CAS + self.assertEqual(response.status_code, 302) + self.assertEqual(response["Location"], "%s/federate/%s?renew=true" % ( + 'http://testserver' if django.VERSION < (1, 9) else "", + provider.suffix + )) + + response = client.get("/federate/%s?renew=true" % provider.suffix) + self.assertEqual(response.status_code, 302) + service_url = ( + "service=http%%3A%%2F%%2Ftestserver%%2Ffederate%%2F%s%%3Frenew%%3Dtrue" + ) % provider.suffix + self.assertIn(service_url, response["Location"]) + self.assertIn("renew=true", response["Location"]) + + cas_port = int(provider.server_url.split(':')[-1]) + # let's generate a ticket + ticket = utils.gen_st() + # we lauch a dummy CAS server that only validate once for the service + # http://testserver/federate/example.com?renew=true with `ticket` + tests_utils.DummyCAS.run( + ("http://testserver/federate/%s?renew=true" % provider.suffix).encode("ascii"), + ticket.encode("ascii"), + settings.CAS_TEST_USER.encode("utf8"), + [], + cas_port + ) + # we normally provide a good ticket and should be redirected to /login as the ticket + # get successfully validated again the dummy CAS + response = client.get( + '/federate/%s' % provider.suffix, + {'ticket': ticket, 'renew': 'true'} + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(response["Location"], "%s/login?renew=true" % ( + 'http://testserver' if django.VERSION < (1, 9) else "" + )) + # follow the redirect and try to get a ticket to see is it has renew set to True + response = client.get("/login?renew=true&service=%s" % self.service) + # we should get a page with a from with all widget hidden that auto POST to /login using + # javascript. If javascript is disabled, a "connect" button is showed + self.assertTrue(response.context['auto_submit']) + self.assertEqual(response.context['post_url'], '/login') + params = tests_utils.copy_form(response.context["form"]) + # POST get prefiled from parameters + response = client.post("/login", params) + self.assertEqual(response.status_code, 302) + self.assertTrue(response["Location"].startswith("%s?ticket=" % self.service)) + ticket_value = response["Location"].split('ticket=')[-1] + ticket = models.ServiceTicket.objects.get(value=ticket_value) + self.assertTrue(ticket.renew) + def test_login_bad_ticket(self): """ Try login with a bad ticket: diff --git a/cas_server/tests/test_models.py b/cas_server/tests/test_models.py index 7a4403c..e0d417e 100644 --- a/cas_server/tests/test_models.py +++ b/cas_server/tests/test_models.py @@ -16,7 +16,9 @@ import django from django.test import TestCase, Client from django.test.utils import override_settings from django.utils import timezone +from django.core import mail +import mock from datetime import timedelta from importlib import import_module @@ -60,6 +62,25 @@ class FederatedUserTestCase(TestCase, UserModels, FederatedIendityProviderModel) with self.assertRaises(models.FederatedUser.DoesNotExist): models.FederatedUser.objects.get(username="test2") + def test_json_attributes(self): + """test the json storage of ``atrributs`` in ``_attributs``""" + provider = models.FederatedIendityProvider.objects.get(suffix="example.com") + user = models.FederatedUser.objects.create( + username=settings.CAS_TEST_USER, + provider=provider, + attributs=settings.CAS_TEST_ATTRIBUTES, + ticket="" + ) + self.assertEqual(utils.json_encode(settings.CAS_TEST_ATTRIBUTES), user._attributs) + user.delete() + user = models.FederatedUser.objects.create( + username=settings.CAS_TEST_USER, + provider=provider, + ticket="" + ) + self.assertIsNone(user._attributs) + self.assertIsNone(user.attributs) + class FederateSLOTestCase(TestCase, UserModels): """test for the federated SLO model""" @@ -231,3 +252,65 @@ class TicketTestCase(TestCase, UserModels, BaseServicePattern): self.assertTrue(b'logoutRequest' in params and params[b'logoutRequest']) # only 1 ticket remain in the db self.assertEqual(len(models.ServiceTicket.objects.all()), 1) + + def test_json_attributes(self): + """test the json storage of ``atrributs`` in ``_attributs``""" + # ge an authenticated client + client = get_auth_client() + # get the user associated to the client + user = self.get_user(client) + ticket = models.ServiceTicket.objects.create( + user=user, + service=self.service, + attributs=settings.CAS_TEST_ATTRIBUTES, + service_pattern=self.service_pattern + ) + self.assertEqual(utils.json_encode(settings.CAS_TEST_ATTRIBUTES), ticket._attributs) + ticket.delete() + ticket = models.ServiceTicket.objects.create( + user=user, + service=self.service, + service_pattern=self.service_pattern + ) + self.assertIsNone(ticket._attributs) + self.assertIsNone(ticket.attributs) + + +@mock.patch("cas_server.utils.last_version", lambda: "1.2.3") +@override_settings(ADMINS=[("Ano Nymous", "ano.nymous@example.net")]) +@override_settings(CAS_NEW_VERSION_EMAIL_WARNING=True) +class NewVersionWarningTestCase(TestCase): + """tests for the new version warning model""" + + @mock.patch("cas_server.models.VERSION", "0.1.2") + def test_send_mails(self): + """test the send_mails method with ADMINS and a new version available""" + models.NewVersionWarning.send_mails() + + self.assertEqual(len(mail.outbox), 1) + self.assertEqual( + mail.outbox[0].subject, + '%sA new version of django-cas-server is available' % settings.EMAIL_SUBJECT_PREFIX + ) + + models.NewVersionWarning.send_mails() + self.assertEqual(len(mail.outbox), 1) + + @mock.patch("cas_server.models.VERSION", "1.2.3") + def test_send_mails_same_version(self): + """test the send_mails method with with current version being the last""" + models.NewVersionWarning.objects.create(version="0.1.2") + models.NewVersionWarning.send_mails() + self.assertEqual(len(mail.outbox), 0) + + @override_settings(ADMINS=[]) + def test_send_mails_no_admins(self): + """test the send_mails method without ADMINS""" + models.NewVersionWarning.send_mails() + self.assertEqual(len(mail.outbox), 0) + + @override_settings(CAS_NEW_VERSION_EMAIL_WARNING=False) + def test_send_mails_disabled(self): + """test the send_mails method if disabled""" + models.NewVersionWarning.send_mails() + self.assertEqual(len(mail.outbox), 0) diff --git a/cas_server/tests/test_utils.py b/cas_server/tests/test_utils.py index f2fcbfc..79c3cb2 100644 --- a/cas_server/tests/test_utils.py +++ b/cas_server/tests/test_utils.py @@ -11,8 +11,11 @@ # (c) 2016 Valentin Samir """Tests module for utils""" from django.test import TestCase, RequestFactory +from django.db import connection import six +import warnings +import datetime from cas_server import utils @@ -128,16 +131,23 @@ class CheckPasswordCase(TestCase): with self.assertRaises(utils.LdapHashUserPassword.BadHash): utils.check_password("ldap", self.password1, b"TOTOssdsdsd", "utf8") for scheme in schemes_salt: + # bad length with self.assertRaises(utils.LdapHashUserPassword.BadHash): utils.check_password("ldap", self.password1, scheme + b"dG90b3E8ZHNkcw==", "utf8") + # bad base64 + with self.assertRaises(utils.LdapHashUserPassword.BadHash): + utils.check_password("ldap", self.password1, scheme + b"dG90b3E8ZHNkcw", "utf8") def test_hex(self): """test all the hex_HASH method: the hashed password is a simple hash of the password""" hashes = ["md5", "sha1", "sha224", "sha256", "sha384", "sha512"] hashed_password1 = [] - for hash in hashes: + for hash_scheme in hashes: hashed_password1.append( - ("hex_%s" % hash, getattr(utils.hashlib, hash)(self.password1).hexdigest()) + ( + "hex_%s" % hash_scheme, + getattr(utils.hashlib, hash_scheme)(self.password1).hexdigest() + ) ) for (method, hp1) in hashed_password1: self.assertTrue(utils.check_password(method, self.password1, hp1, "utf8")) @@ -208,3 +218,40 @@ class UtilsTestCase(TestCase): self.assertEqual(utils.get_tuple(test_tuple, 3), None) self.assertEqual(utils.get_tuple(test_tuple, 3, 'toto'), 'toto') self.assertEqual(utils.get_tuple(None, 3), None) + + def test_last_version(self): + """ + test the function last_version. An internet connection is needed, if you do not have + one, this test will fail and you should ignore it. + """ + try: + # first check if pypi is available + utils.requests.get("https://pypi.python.org/simple/django-cas-server/") + except utils.requests.exceptions.RequestException: + warnings.warn( + ( + "Pypi seems not available, perhaps you do not have internet access. " + "Consequently, the test cas_server.tests.test_utils.UtilsTestCase.test_last_" + "version is ignored" + ), + RuntimeWarning + ) + else: + version = utils.last_version() + self.assertIsInstance(version, six.text_type) + self.assertEqual(len(version.split('.')), 3) + + # version is cached 24h so calling it a second time should return the save value + self.assertEqual(version, utils.last_version()) + + def test_dictfetchall(self): + """test the function dictfetchall""" + with connection.cursor() as curs: + curs.execute("SELECT * FROM django_migrations") + results = utils.dictfetchall(curs) + self.assertIsInstance(results, list) + self.assertTrue(len(results) > 0) + for result in results: + self.assertIsInstance(result, dict) + self.assertIn('applied', result) + self.assertIsInstance(result['applied'], datetime.datetime) diff --git a/cas_server/tests/test_view.py b/cas_server/tests/test_view.py index 7ae0888..1297e4a 100644 --- a/cas_server/tests/test_view.py +++ b/cas_server/tests/test_view.py @@ -20,6 +20,7 @@ from django.utils import timezone import random import json +import mock from lxml import etree from six.moves import range @@ -47,6 +48,33 @@ class LoginTestCase(TestCase, BaseServicePattern, CanLogin): # we prepare a bunch a service url and service patterns for tests self.setup_service_patterns() + @override_settings(CAS_NEW_VERSION_HTML_WARNING=True) + @mock.patch("cas_server.utils.last_version", lambda: "1.2.3") + @mock.patch("cas_server.utils.VERSION", "0.1.2") + def test_new_version_available_ok(self): + """test the new version info box""" + client = Client() + response = client.get("/login") + self.assertIn(b"A new version of the application is available", response.content) + + @override_settings(CAS_NEW_VERSION_HTML_WARNING=True) + @mock.patch("cas_server.utils.last_version", lambda: None) + @mock.patch("cas_server.utils.VERSION", "0.1.2") + def test_new_version_available_badpypi(self): + """ + test the new version info box if pypi is not available (unable to retreive last version) + """ + client = Client() + response = client.get("/login") + self.assertNotIn(b"A new version of the application is available", response.content) + + @override_settings(CAS_NEW_VERSION_HTML_WARNING=False) + def test_new_version_available_disabled(self): + """test the new version info box is disabled""" + client = Client() + response = client.get("/login") + self.assertNotIn(b"A new version of the application is available", response.content) + def test_login_view_post_goodpass_goodlt(self): """Test a successul login""" # we get a client who fetch a frist time the login page and the login form default @@ -309,7 +337,7 @@ class LoginTestCase(TestCase, BaseServicePattern, CanLogin): response = client.get("/login", {'service': service}) # the ticket is not created and a warning is displayed to the user self.assertEqual(response.status_code, 200) - self.assertTrue(b"User charateristics non allowed" in response.content) + self.assertTrue(b"User characteristics non allowed" in response.content) # same but with rectriction that a valid upon the test user attributes response = client.get("/login", {'service': self.service_filter_success}) @@ -327,7 +355,7 @@ class LoginTestCase(TestCase, BaseServicePattern, CanLogin): response = client.get("/login", {'service': self.service_field_needed_fail}) # the ticket is not created and a warning is displayed to the user self.assertEqual(response.status_code, 200) - self.assertTrue(b"The attribut uid is needed to use that service" in response.content) + self.assertTrue(b"The attribute uid is needed to use that service" in response.content) # same but with a attribute that the test user has response = client.get("/login", {'service': self.service_field_needed_success}) @@ -351,7 +379,7 @@ class LoginTestCase(TestCase, BaseServicePattern, CanLogin): response = client.get("/login", {"service": self.service_field_needed_success}) # the ticket is not created and a warning is displayed to the user self.assertEqual(response.status_code, 200) - self.assertTrue(b"The attribut alias is needed to use that service" in response.content) + self.assertTrue(b"The attribute alias is needed to use that service" in response.content) def test_gateway(self): """test gateway parameter""" diff --git a/cas_server/tests/utils.py b/cas_server/tests/utils.py index 515b653..b13874c 100644 --- a/cas_server/tests/utils.py +++ b/cas_server/tests/utils.py @@ -12,8 +12,9 @@ """Some utils functions for tests""" from cas_server.default_settings import settings +import django from django.test import Client -from django.template import loader, Context +from django.template import loader from django.utils import timezone import cgi @@ -21,13 +22,25 @@ import six from threading import Thread from lxml import etree from six.moves import BaseHTTPServer -from six.moves.urllib.parse import urlparse, parse_qsl +from six.moves.urllib.parse import urlparse, parse_qsl, parse_qs from datetime import timedelta from cas_server import models from cas_server import utils +if django.VERSION < (1, 8): + from django.template import Context +else: + def Context(arg): + """ + Starting from django 1.8 render take a dict and deprecated the use of a Context. + So this is the identity function, only use for compatibility with django 1.7 where + render MUST take a Context as argument. + """ + return arg + + def return_unicode(string, charset): """make `string` a unicode if `string` is a unicode or bytes encoded with `charset`""" if not isinstance(string, six.text_type): @@ -166,7 +179,7 @@ class HttpParamsHandler(BaseHTTPServer.BaseHTTPRequestHandler): postvars = cgi.parse_multipart(self.rfile, pdict) elif ctype == 'application/x-www-form-urlencoded': length = int(self.headers.get('content-length')) - postvars = cgi.parse_qs(self.rfile.read(length), keep_blank_values=1) + postvars = parse_qs(self.rfile.read(length), keep_blank_values=1) else: postvars = {} self.server.PARAMS = postvars diff --git a/cas_server/urls.py b/cas_server/urls.py index aa014f2..a9cac30 100644 --- a/cas_server/urls.py +++ b/cas_server/urls.py @@ -16,8 +16,10 @@ from django.views.decorators.debug import sensitive_post_parameters, sensitive_v from cas_server import views +app_name = "cas_server" + urlpatterns = [ - url(r'^$', RedirectView.as_view(pattern_name="cas_server:login")), + url(r'^$', RedirectView.as_view(pattern_name="cas_server:login", permanent=False)), url( '^login$', sensitive_post_parameters('password')( @@ -51,8 +53,8 @@ urlpatterns = [ url('^samlValidate$', views.SamlValidate.as_view(), name='samlValidate'), url( '^auth$', - sensitive_variables('password')( - sensitive_post_parameters('password')( + sensitive_variables('password', 'secret')( + sensitive_post_parameters('password', 'secret')( views.Auth.as_view() ) ), diff --git a/cas_server/utils.py b/cas_server/utils.py index 6b7f842..c94ddf5 100644 --- a/cas_server/utils.py +++ b/cas_server/utils.py @@ -25,11 +25,20 @@ import hashlib import crypt import base64 import six +import requests +import time +import logging +import binascii from importlib import import_module from datetime import datetime, timedelta from six.moves.urllib.parse import urlparse, urlunparse, parse_qsl, urlencode +from . import VERSION + +#: logger facility +logger = logging.getLogger(__name__) + def json_encode(obj): """Encode a python object to json""" @@ -51,6 +60,14 @@ def context(params): """ params["settings"] = settings params["message_levels"] = DEFAULT_MESSAGE_LEVELS + if settings.CAS_NEW_VERSION_HTML_WARNING: + LAST_VERSION = last_version() + params["VERSION"] = VERSION + params["LAST_VERSION"] = LAST_VERSION + if LAST_VERSION is not None: + params["upgrade_available"] = decode_version(VERSION) < decode_version(LAST_VERSION) + else: + params["upgrade_available"] = False return params @@ -545,7 +562,10 @@ class LdapHashUserPassword(object): elif scheme == b'{CRYPT}': return b'$'.join(hashed_passord.split(b'$', 3)[:-1])[len(scheme):] else: - hashed_passord = base64.b64decode(hashed_passord[len(scheme):]) + try: + hashed_passord = base64.b64decode(hashed_passord[len(scheme):]) + except (TypeError, binascii.Error) as error: + raise cls.BadHash("Bad base64: %s" % error) if len(hashed_passord) < cls._schemes_to_len[scheme]: raise cls.BadHash("Hash too short for the scheme %s" % scheme) return hashed_passord[cls._schemes_to_len[scheme]:] @@ -563,7 +583,7 @@ def check_password(method, password, hashed_password, charset): :param hashed_password: The hashed password as stored in the database :type hashed_password: :obj:`str` or :obj:`unicode` :param str charset: The used char encoding (also used internally, so it must be valid for - the charset used by ``password`` even if it is inputed as an :obj:`unicode`) + the charset used by ``password`` when it was initially ) :return: True if ``password`` match ``hashed_password`` using ``method``, ``False`` otherwise :rtype: bool @@ -603,3 +623,60 @@ def check_password(method, password, hashed_password, charset): )(password).hexdigest().encode("ascii") == hashed_password.lower() else: raise ValueError("Unknown password method check %r" % method) + + +def decode_version(version): + """ + decode a version string following version semantic http://semver.org/ input a tuple of int + + :param unicode version: A dotted version + :return: A tuple a int + :rtype: tuple + """ + return tuple(int(sub_version) for sub_version in version.split('.')) + + +def last_version(): + """ + Fetch the last version from pypi and return it. On successful fetch from pypi, the response + is cached 24h, on error, it is cached 10 min. + + :return: the last django-cas-server version + :rtype: unicode + """ + try: + last_update, version, success = last_version._cache + except AttributeError: + last_update = 0 + version = None + success = False + cache_delta = 24 * 3600 if success else 600 + if (time.time() - last_update) < cache_delta: + return version + else: + try: + req = requests.get(settings.CAS_NEW_VERSION_JSON_URL) + data = json.loads(req.text) + versions = list(data["releases"].keys()) + versions.sort() + version = versions[-1] + last_version._cache = (time.time(), version, True) + return version + except ( + KeyError, + ValueError, + requests.exceptions.RequestException + ) as error: # pragma: no cover (should not happen unless pypi is not available) + logger.error( + "Unable to fetch %s: %s" % (settings.CAS_NEW_VERSION_JSON_URL, error) + ) + last_version._cache = (time.time(), version, False) + + +def dictfetchall(cursor): + "Return all rows from a django cursor as a dict" + columns = [col[0] for col in cursor.description] + return [ + dict(zip(columns, row)) + for row in cursor.fetchall() + ] diff --git a/cas_server/views.py b/cas_server/views.py index 0a3b24f..e810f54 100644 --- a/cas_server/views.py +++ b/cas_server/views.py @@ -147,9 +147,12 @@ class LogoutView(View, LogoutMixin): # current querystring if settings.CAS_FEDERATE: if auth is not None: - params = utils.copy_params(request.GET) + params = utils.copy_params(request.GET, ignore={"forget_provider"}) url = auth.get_logout_url() - return HttpResponseRedirect(utils.update_url(url, params)) + response = HttpResponseRedirect(utils.update_url(url, params)) + if request.GET.get("forget_provider"): + response.delete_cookie("remember_provider") + return response # if service is set, redirect to service after logout if self.service: list(messages.get_messages(request)) # clean messages before leaving the django app @@ -209,6 +212,7 @@ class LogoutView(View, LogoutMixin): class FederateAuth(View): """view to authenticated user agains a backend CAS then CAS_FEDERATE is True""" + @method_decorator(csrf_exempt) # csrf is disabled for allowing SLO requests reception def dispatch(self, request, *args, **kwargs): """ @@ -218,8 +222,7 @@ class FederateAuth(View): """ return super(FederateAuth, self).dispatch(request, *args, **kwargs) - @staticmethod - def get_cas_client(request, provider): + def get_cas_client(self, request, provider, renew=False): """ return a CAS client object matching provider @@ -231,7 +234,8 @@ class FederateAuth(View): """ # compute the current url, ignoring ticket dans provider GET parameters service_url = utils.get_current_url(request, {"ticket", "provider"}) - return CASFederateValidateUser(provider, service_url) + self.service_url = service_url + return CASFederateValidateUser(provider, service_url, renew=renew) def post(self, request, provider=None): """ @@ -264,24 +268,16 @@ class FederateAuth(View): if form.is_valid(): params = utils.copy_params( request.POST, - ignore={"provider", "csrfmiddlewaretoken", "ticket"} + ignore={"provider", "csrfmiddlewaretoken", "ticket", "lt"} ) + if params.get("renew") == "False": + del params["renew"] url = utils.reverse_params( "cas_server:federateAuth", kwargs=dict(provider=form.cleaned_data["provider"].suffix), params=params ) - response = HttpResponseRedirect(url) - # If the user has checked "remember my identity provider" store it in a cookie - if form.cleaned_data["remember"]: - max_age = settings.CAS_FEDERATE_REMEMBER_TIMEOUT - utils.set_cookie( - response, - "_remember_provider", - form.cleaned_data["provider"].suffix, - max_age - ) - return response + return HttpResponseRedirect(url) else: return redirect("cas_server:login") @@ -296,47 +292,81 @@ class FederateAuth(View): if not settings.CAS_FEDERATE: logger.warning("CAS_FEDERATE is False, set it to True to use the federated mode") 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"): + if self.request.session.get("authenticated") and not renew: logger.warning("User already authenticated, dropping federate authentication request") return redirect("cas_server:login") try: # get the identity provider from its suffix provider = FederatedIendityProvider.objects.get(suffix=provider) # get a CAS client for the user identity provider - auth = self.get_cas_client(request, provider) + 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) return HttpResponseRedirect(auth.get_login_url()) else: ticket = request.GET['ticket'] - # if the ticket validation succeed - if auth.verify_ticket(ticket): - logger.info( - "Got a valid ticket for %s from %s" % ( - auth.username, - auth.provider.server_url + try: + # if the ticket validation succeed + if auth.verify_ticket(ticket): + logger.info( + "Got a valid ticket for %s from %s" % ( + auth.username, + auth.provider.server_url + ) ) - ) - params = utils.copy_params(request.GET, ignore={"ticket"}) - request.session["federate_username"] = auth.federated_username - request.session["federate_ticket"] = ticket - auth.register_slo(auth.federated_username, request.session.session_key, ticket) - # redirect to the the login page for the user to become authenticated - # thanks to the `federate_username` and `federate_ticket` session parameters - url = utils.reverse_params("cas_server:login", params) - return HttpResponseRedirect(url) - # else redirect to the identity provider CAS login page - else: - logger.info( - "Got a invalid ticket for %s from %s. Retrying to authenticate" % ( - auth.username, - auth.provider.server_url + params = utils.copy_params(request.GET, ignore={"ticket", "remember"}) + request.session["federate_username"] = auth.federated_username + request.session["federate_ticket"] = ticket + auth.register_slo( + auth.federated_username, + request.session.session_key, + ticket ) + # redirect to the the login page for the user to become authenticated + # thanks to the `federate_username` and `federate_ticket` session parameters + url = utils.reverse_params("cas_server:login", params) + response = HttpResponseRedirect(url) + # If the user has checked "remember my identity provider" store it in a + # cookie + if request.GET.get("remember"): + max_age = settings.CAS_FEDERATE_REMEMBER_TIMEOUT + utils.set_cookie( + response, + "remember_provider", + provider.suffix, + max_age + ) + return response + # else redirect to the identity provider CAS login page + else: + logger.info( + ( + "Got a invalid ticket %s from %s for service %s. " + "Retrying to authenticate" + ) % ( + ticket, + auth.provider.server_url, + self.service_url + ) + ) + return HttpResponseRedirect(auth.get_login_url()) + # both xml.etree.ElementTree and lxml.etree exceptions inherit from SyntaxError + except SyntaxError as error: + messages.add_message( + request, + messages.ERROR, + _( + u"Invalid response from your identity provider CAS upon " + u"ticket %(ticket)s validation: %(error)r" + ) % {'ticket': ticket, 'error': error} ) - return HttpResponseRedirect(auth.get_login_url()) + response = redirect("cas_server:login") + response.delete_cookie("remember_provider") + return response except FederatedIendityProvider.DoesNotExist: logger.warning("Identity provider suffix %s not found" % provider) # if the identity provider is not found, redirect to the login page @@ -407,7 +437,8 @@ class LoginView(View, LogoutMixin): self.warn = request.POST.get('warn') if settings.CAS_FEDERATE: self.username = request.POST.get('username') - self.ticket = request.POST.get('ticket') + # in federated mode, the valdated indentity provider CAS ticket is used as password + self.ticket = request.POST.get('password') def gen_lt(self): """Generate a new LoginTicket and add it to the list of valid LT for the user""" @@ -451,7 +482,7 @@ class LoginView(View, LogoutMixin): messages.add_message( self.request, messages.ERROR, - _(u"Invalid login ticket") + _(u"Invalid login ticket, please retry to login") ) elif ret == self.USER_LOGIN_OK: # On successful login, update the :class:`models.User` ``date`` @@ -477,7 +508,17 @@ class LoginView(View, LogoutMixin): else: # pragma: no cover (should no happen) raise EnvironmentError("invalid output for LoginView.process_post") # call the GET/POST common part - return self.common() + response = self.common() + if self.warn: + utils.set_cookie( + response, + "warn", + "on", + 10 * 365 * 24 * 3600 + ) + else: + response.delete_cookie("warn") + return response def process_post(self): """ @@ -586,7 +627,9 @@ class LoginView(View, LogoutMixin): form_initial = { 'service': self.service, 'method': self.method, - 'warn': self.warn or self.request.session.get("warn"), + 'warn': ( + self.warn or self.request.session.get("warn") or self.request.COOKIES.get('warn') + ), 'lt': self.request.session['lt'][-1], 'renew': self.renew } @@ -683,14 +726,14 @@ class LoginView(View, LogoutMixin): messages.add_message( self.request, messages.ERROR, - _(u"User charateristics non allowed") + _(u"User characteristics non allowed") ) except models.UserFieldNotDefined: error = 4 messages.add_message( self.request, messages.ERROR, - _(u"The attribut %(field)s is needed to use" + _(u"The attribute %(field)s is needed to use" u" that service") % {'field': service_pattern.user_field} ) @@ -817,19 +860,37 @@ class LoginView(View, LogoutMixin): ) else: if ( - self.request.COOKIES.get('_remember_provider') and + self.request.COOKIES.get('remember_provider') and FederatedIendityProvider.objects.filter( - suffix=self.request.COOKIES['_remember_provider'] + suffix=self.request.COOKIES['remember_provider'] ) ): params = utils.copy_params(self.request.GET) url = utils.reverse_params( "cas_server:federateAuth", params=params, - kwargs=dict(provider=self.request.COOKIES['_remember_provider']) + kwargs=dict(provider=self.request.COOKIES['remember_provider']) ) return HttpResponseRedirect(url) else: + # if user is authenticated and auth renewal is requested, redirect directly + # to the user identity provider + if self.renew and self.request.session.get("authenticated"): + try: + user = FederatedUser.get_from_federated_username( + self.request.session.get("username") + ) + params = utils.copy_params(self.request.GET) + url = utils.reverse_params( + "cas_server:federateAuth", + params=params, + kwargs=dict(provider=user.provider.suffix) + ) + return HttpResponseRedirect(url) + # Should normally not happen: if the user is logged, it exists in the + # database. + except FederatedUser.DoesNotExist: # pragma: no cover + pass return render( self.request, settings.CAS_LOGIN_TEMPLATE, diff --git a/requirements-dev.txt b/requirements-dev.txt index c394fb1..f92821a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,10 +1,12 @@ setuptools>=5.5 +requests>=2.4 +requests_futures>=0.9.5 +lxml>=3.4 +six>=1.8 tox>=1.8.1 pytest>=2.6.4 pytest-django>=2.8.0 pytest-pythonpath>=0.3 +pytest-warnings pytest-cov>=2.2.1 -requests>=2.4 -requests_futures>=0.9.5 -lxml>=3.4 -six>=1 +mock>=1 diff --git a/requirements.txt b/requirements.txt index 3e1c140..b7518f5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ -Django >= 1.8,<1.10 +Django >= 1.7.1,<1.10 setuptools>=5.5 requests>=2.4 requests_futures>=0.9.5 lxml>=3.4 -six>=1 +six>=1.8 diff --git a/setup.py b/setup.py index b27f5b4..e33f30e 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,7 @@ import os import pkg_resources from setuptools import setup - -VERSION = '0.6.1' +from cas_server import VERSION with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as readme: README = readme.read() @@ -30,7 +29,7 @@ if __name__ == '__main__': author_email='valentin.samir@crans.org', classifiers=[ 'Environment :: Web Environment', - 'evelopment Status :: 5 - Production/Stable', + 'Development Status :: 5 - Production/Stable', 'Framework :: Django', 'Framework :: Django :: 1.7', 'Framework :: Django :: 1.8', @@ -66,5 +65,5 @@ if __name__ == '__main__': download_url="https://github.com/nitmir/django-cas-server/releases", zip_safe=False, setup_requires=['pytest-runner'], - tests_require=['pytest', 'pytest-django', 'pytest-pythonpath'], + tests_require=['pytest', 'pytest-django', 'pytest-pythonpath', 'pytest-warnings', 'mock>=1'], ) diff --git a/tox.ini b/tox.ini index bdf50f0..401c249 100644 --- a/tox.ini +++ b/tox.ini @@ -31,7 +31,7 @@ whitelist_externals= [testenv] commands= - py.test {posargs:cas_server/tests/} + py.test -rw {posargs:cas_server/tests/} {[post_cmd]commands} whitelist_externals={[post_cmd]whitelist_externals}