diff --git a/.gitignore b/.gitignore index 273399d..c05c31f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,12 +4,13 @@ *.swp build/ -bootstrap3 cas/ dist/ db.sqlite3 manage.py coverage.xml +docs/_build/ +docs/django.inv .tox test_venv @@ -17,3 +18,4 @@ test_venv htmlcov/ tox_logs/ .cache/ +.eggs/ diff --git a/.travis.yml b/.travis.yml index d4b800e..e7583b2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,17 +1,28 @@ language: python -python: - - "2.7" -env: - matrix: - - TOX_ENV=coverage - - TOX_ENV=flake8 - - TOX_ENV=check_rst - - TOX_ENV=py27-django17 - - TOX_ENV=py27-django18 - - TOX_ENV=py27-django19 - - TOX_ENV=py34-django17 - - TOX_ENV=py34-django18 - - TOX_ENV=py34-django19 +matrix: + include: + - python: "2.7" + env: TOX_ENV=coverage + - python: "2.7" + env: TOX_ENV=flake8 + - python: "2.7" + env: TOX_ENV=check_rst + - python: "2.7" + env: TOX_ENV=py27-django17 + - python: "2.7" + env: TOX_ENV=py27-django18 + - python: "2.7" + env: TOX_ENV=py27-django19 + - python: "3.4" + env: TOX_ENV=py34-django17 + - python: "3.4" + env: TOX_ENV=py34-django18 + - python: "3.4" + env: TOX_ENV=py34-django19 + - python: "3.5" + env: TOX_ENV=py35-django18 + - python: "3.5" + env: TOX_ENV=py35-django19 cache: directories: - $HOME/.cache/pip/http/ diff --git a/MANIFEST.in b/MANIFEST.in index bc6a3b2..3f968f7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,7 +1,21 @@ include tox.ini include LICENSE include README.rst +include .coveragerc +include Makefile +include pytest.ini +include requirements-dev.txt +include requirements.txt prune .tox recursive-include cas_server/static * recursive-include cas_server/templates * recursive-include cas_server/locale * + +include docs/conf.py +include docs/index.rst +include docs/Makefile +include docs/README.rst +recursive-include docs/_ext * +recursive-include docs/package * +recursive-include docs/_static * +recursive-include docs/_templates * diff --git a/Makefile b/Makefile index 7a44d9b..c719834 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build dist +.PHONY: build dist docs VERSION=`python setup.py -V` build: @@ -24,10 +24,14 @@ clean_coverage: rm -rf coverage.xml .coverage htmlcov clean_tild_backup: find ./ -name '*~' -delete +clean_docs: + rm -rf docs/_build/ docs/django.inv +clean_eggs: + rm -rf .eggs/ clean: clean_pyc clean_build clean_coverage clean_tild_backup -clean_all: clean clean_tox clean_test_venv +clean_all: clean clean_tox clean_test_venv clean_docs clean_eggs dist: python setup.py sdist @@ -40,7 +44,7 @@ test_venv/cas/manage.py: test_venv mkdir -p test_venv/cas test_venv/bin/django-admin startproject cas test_venv/cas ln -s ../../cas_server test_venv/cas/cas_server - sed -i "s/'django.contrib.staticfiles',/'django.contrib.staticfiles',\n 'bootstrap3',\n 'cas_server',/" test_venv/cas/cas/settings.py + sed -i "s/'django.contrib.staticfiles',/'django.contrib.staticfiles',\n 'cas_server',/" test_venv/cas/cas/settings.py sed -i "s/'django.middleware.clickjacking.XFrameOptionsMiddleware',/'django.middleware.clickjacking.XFrameOptionsMiddleware',\n 'django.middleware.locale.LocaleMiddleware',/" test_venv/cas/cas/settings.py sed -i 's/from django.conf.urls import url/from django.conf.urls import url, include/' test_venv/cas/cas/urls.py sed -i "s@url(r'^admin/', admin.site.urls),@url(r'^admin/', admin.site.urls),\n url(r'^', include('cas_server.urls', namespace='cas_server')),@" test_venv/cas/cas/urls.py @@ -60,3 +64,12 @@ run_tests: test_venv python setup.py check --restructuredtext --stric test_venv/bin/py.test --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 + test_venv/bin/pip install Sphinx sphinx_rtd_theme + +docs: test_venv/bin/sphinx-build + bash -c "source test_venv/bin/activate; cd docs; make html" + +publish_pypi_release: + python setup.py sdist bdist_wheel upload --sign diff --git a/README.rst b/README.rst index 9c3939d..8675112 100644 --- a/README.rst +++ b/README.rst @@ -1,20 +1,7 @@ CAS Server ########## -.. image:: https://travis-ci.org/nitmir/django-cas-server.svg?branch=master - :target: https://travis-ci.org/nitmir/django-cas-server - -.. image:: https://img.shields.io/pypi/v/django-cas-server.svg - :target: https://pypi.python.org/pypi/django-cas-server - -.. image:: https://img.shields.io/pypi/l/django-cas-server.svg - :target: https://www.gnu.org/licenses/gpl-3.0.html - -.. image:: https://api.codacy.com/project/badge/Grade/255c21623d6946ef8802fa7995b61366 - :target: https://www.codacy.com/app/valentin-samir/django-cas-server - -.. image:: https://api.codacy.com/project/badge/Coverage/255c21623d6946ef8802fa7995b61366 - :target: https://www.codacy.com/app/valentin-samir/django-cas-server +|travis| |version| |lisence| |codacy| |coverage| CAS Server is a Django application implementing the `CAS Protocol 3.0 Specification `_. @@ -22,13 +9,6 @@ CAS Server is a Django application implementing the `CAS Protocol 3.0 Specificat By default, the authentication process use django internal users but you can easily use any sources (see auth classes in the auth.py file) -The default login/logout template use `django-bootstrap3 `__ -but you can use your own templates using settings variables. - -Note that for Django 1.7 compatibility, you need a version of -`django-bootstrap3 `__ < 7.0.0 -like the 6.2.2 version. - .. contents:: Table of Contents Features @@ -52,8 +32,6 @@ Dependencies * Django >= 1.7 < 1.10 * requests >= 2.4 * requests_futures >= 0.9.5 -* django-picklefield >= 0.3.1 -* django-bootstrap3 >= 5.4 (< 7.0.0 if using django 1.7) * lxml >= 3.4 * six >= 1 @@ -68,7 +46,7 @@ The recommended installation mode is to use a virtualenv with ``--system-site-pa On debian like systems:: - $ sudo apt-get install python-django python-requests python-django-picklefield python-six python-lxml + $ sudo apt-get install python-django python-requests python-six python-lxml python-requests-futures On debian jessie, you can use the version of python-django available in the `backports `_. @@ -118,7 +96,6 @@ Quick start INSTALLED_APPS = ( 'django.contrib.admin', ... - 'bootstrap3', 'cas_server', ) @@ -186,6 +163,17 @@ 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_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:: + + { + "bootstrap3_css": "//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css", + "bootstrap3_js": "//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/js/bootstrap.min.js", + "html5shiv": "//oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js", + "respond": "//oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js", + "jquery": "//code.jquery.com/jquery.min.js", + } * ``CAS_LOGIN_TEMPLATE``: Path to the template showed on ``/login`` then the user is not autenticated. The default is ``"cas_server/login.html"``. @@ -489,3 +477,20 @@ You could for example do as bellow : .. code-block:: 10 0 * * * cas-user /path/to/project/manage.py cas_clean_federate + + + +.. |travis| image:: https://badges.genua.fr/travis/nitmir/django-cas-server/master.svg + :target: https://travis-ci.org/nitmir/django-cas-server + +.. |version| image:: https://badges.genua.fr/pypi/v/django-cas-server.svg + :target: https://pypi.python.org/pypi/django-cas-server + +.. |lisence| image:: https://badges.genua.fr/pypi/l/django-cas-server.svg + :target: https://www.gnu.org/licenses/gpl-3.0.html + +.. |codacy| image:: https://badges.genua.fr/codacy/grade/255c21623d6946ef8802fa7995b61366/master.svg + :target: https://www.codacy.com/app/valentin-samir/django-cas-server + +.. |coverage| image:: https://badges.genua.fr/codacy/coverage/255c21623d6946ef8802fa7995b61366/master.svg + :target: https://www.codacy.com/app/valentin-samir/django-cas-server diff --git a/cas_server/__init__.py b/cas_server/__init__.py index 29f5de6..085927b 100644 --- a/cas_server/__init__.py +++ b/cas_server/__init__.py @@ -9,4 +9,5 @@ # # (c) 2015-2016 Valentin Samir """A django CAS server application""" +#: path the the application configuration class default_app_config = 'cas_server.apps.CasAppConfig' diff --git a/cas_server/admin.py b/cas_server/admin.py index 848b481..6e5c318 100644 --- a/cas_server/admin.py +++ b/cas_server/admin.py @@ -15,86 +15,155 @@ from .models import Username, ReplaceAttributName, ReplaceAttributValue, FilterA from .models import FederatedIendityProvider from .forms import TicketForm -TICKETS_READONLY_FIELDS = ('validate', 'service', 'service_pattern', - 'creation', 'renew', 'single_log_out', 'value') -TICKETS_FIELDS = ('validate', 'service', 'service_pattern', - 'creation', 'renew', 'single_log_out') + +class BaseInlines(admin.TabularInline): + """ + Bases: :class:`django.contrib.admin.TabularInline` + + Base class for inlines in the admin interface. + """ + #: This controls the number of extra forms the formset will display in addition to + #: the initial forms. + extra = 0 -class ServiceTicketInline(admin.TabularInline): - """`ServiceTicket` in admin interface""" +class UserAdminInlines(BaseInlines): + """ + Bases: :class:`BaseInlines` + + Base class for inlines in :class:`UserAdmin` interface + """ + #: The form :class:`TicketForm` used to display tickets. + form = TicketForm + #: Fields to display on a object that are read only (not editable). + readonly_fields = ( + 'validate', 'service', 'service_pattern', + 'creation', 'renew', 'single_log_out', 'value' + ) + #: Fields to display on a object. + fields = ( + 'validate', 'service', 'service_pattern', + 'creation', 'renew', 'single_log_out' + ) + + +class ServiceTicketInline(UserAdminInlines): + """ + Bases: :class:`UserAdminInlines` + + :class:`ServiceTicket` in admin interface + """ + #: The model which the inline is using. model = ServiceTicket - extra = 0 - form = TicketForm - readonly_fields = TICKETS_READONLY_FIELDS - fields = TICKETS_FIELDS -class ProxyTicketInline(admin.TabularInline): - """`ProxyTicket` in admin interface""" +class ProxyTicketInline(UserAdminInlines): + """ + Bases: :class:`UserAdminInlines` + + :class:`ProxyTicket` in admin interface + """ + #: The model which the inline is using. model = ProxyTicket - extra = 0 - form = TicketForm - readonly_fields = TICKETS_READONLY_FIELDS - fields = TICKETS_FIELDS -class ProxyGrantingInline(admin.TabularInline): - """`ProxyGrantingTicket` in admin interface""" +class ProxyGrantingInline(UserAdminInlines): + """ + Bases: :class:`UserAdminInlines` + + :class:`ProxyGrantingTicket` in admin interface + """ + #: The model which the inline is using. model = ProxyGrantingTicket - extra = 0 - form = TicketForm - readonly_fields = TICKETS_READONLY_FIELDS - fields = TICKETS_FIELDS[1:] class UserAdmin(admin.ModelAdmin): - """`User` in admin interface""" + """ + Bases: :class:`django.contrib.admin.ModelAdmin` + + :class:`User` in admin interface + """ + #: See :class:`ServiceTicketInline`, :class:`ProxyTicketInline`, :class:`ProxyGrantingInline` + #: objects below the :class:`UserAdmin` fields. inlines = (ServiceTicketInline, ProxyTicketInline, ProxyGrantingInline) + #: Fields to display on a object that are read only (not editable). readonly_fields = ('username', 'date', "session_key") + #: Fields to display on a object. fields = ('username', 'date', "session_key") + #: Fields to display on the list of class:`UserAdmin` objects. list_display = ('username', 'date', "session_key") -class UsernamesInline(admin.TabularInline): - """`Username` in admin interface""" +class UsernamesInline(BaseInlines): + """ + Bases: :class:`BaseInlines` + + :class:`Username` in admin interface + """ + #: The model which the inline is using. model = Username - extra = 0 -class ReplaceAttributNameInline(admin.TabularInline): - """`ReplaceAttributName` in admin interface""" +class ReplaceAttributNameInline(BaseInlines): + """ + Bases: :class:`BaseInlines` + + :class:`ReplaceAttributName` in admin interface + """ + #: The model which the inline is using. model = ReplaceAttributName - extra = 0 -class ReplaceAttributValueInline(admin.TabularInline): - """`ReplaceAttributValue` in admin interface""" +class ReplaceAttributValueInline(BaseInlines): + """ + Bases: :class:`BaseInlines` + + :class:`ReplaceAttributValue` in admin interface + """ + #: The model which the inline is using. model = ReplaceAttributValue - extra = 0 -class FilterAttributValueInline(admin.TabularInline): - """`FilterAttributValue` in admin interface""" +class FilterAttributValueInline(BaseInlines): + """ + Bases: :class:`BaseInlines` + + :class:`FilterAttributValue` in admin interface + """ + #: The model which the inline is using. model = FilterAttributValue - extra = 0 class ServicePatternAdmin(admin.ModelAdmin): - """`ServicePattern` in admin interface""" + """ + Bases: :class:`django.contrib.admin.ModelAdmin` + + :class:`ServicePattern` in admin interface + """ + #: See :class:`UsernamesInline`, :class:`ReplaceAttributNameInline`, + #: :class:`ReplaceAttributValueInline`, :class:`FilterAttributValueInline` objects below + #: the :class:`ServicePatternAdmin` fields. inlines = ( UsernamesInline, ReplaceAttributNameInline, ReplaceAttributValueInline, FilterAttributValueInline ) + #: Fields to display on the list of class:`ServicePatternAdmin` objects. list_display = ('pos', 'name', 'pattern', 'proxy', 'single_log_out', 'proxy_callback', 'restrict_users') class FederatedIendityProviderAdmin(admin.ModelAdmin): - """`FederatedIendityProvider` in admin interface""" + """ + Bases: :class:`django.contrib.admin.ModelAdmin` + + :class:`FederatedIendityProvider` in admin + interface + """ + #: Fields to display on a object. fields = ('pos', 'suffix', 'server_url', 'cas_protocol_version', 'verbose_name', 'display') + #: Fields to display on the list of class:`FederatedIendityProviderAdmin` objects. list_display = ('verbose_name', 'suffix', 'display') diff --git a/cas_server/apps.py b/cas_server/apps.py index ea15273..03afab5 100644 --- a/cas_server/apps.py +++ b/cas_server/apps.py @@ -14,6 +14,12 @@ from django.apps import AppConfig class CasAppConfig(AppConfig): - """django CAS application config class""" + """ + Bases: :class:`django.apps.AppConfig` + + django CAS application config class + """ + #: Full Python path to the application. It must be unique across a Django project. name = 'cas_server' + #: Human-readable name for the application. verbose_name = _('Central Authentication Service') diff --git a/cas_server/auth.py b/cas_server/auth.py index 9f40ae4..31aa4f2 100644 --- a/cas_server/auth.py +++ b/cas_server/auth.py @@ -26,55 +26,112 @@ from .models import FederatedUser class AuthUser(object): - """Authentication base class""" + """ + Authentication base class + + :param unicode username: A username, stored in the :attr:`username` class attribute. + """ + + #: username used to instanciate the current object + username = None + def __init__(self, username): self.username = username def test_password(self, password): - """test `password` agains the user""" + """ + Tests ``password`` agains the user password. + + :raises NotImplementedError: always. The method need to be implemented by subclasses + """ raise NotImplementedError() def attributs(self): - """return a dict of user attributes""" + """ + The user attributes. + + raises NotImplementedError: always. The method need to be implemented by subclasses + """ raise NotImplementedError() class DummyAuthUser(AuthUser): # pragma: no cover - """A Dummy authentication class""" + """ + A Dummy authentication class. Authentication always fails - def __init__(self, username): - super(DummyAuthUser, self).__init__(username) + :param unicode username: A username, stored in the :attr:`username` + class attribute. There is no valid value for this attribute here. + """ def test_password(self, password): - """test `password` agains the user""" + """ + Tests ``password`` agains the user password. + + :param unicode password: a clear text password as submited by the user. + :return: always ``False`` + :rtype: bool + """ return False def attributs(self): - """return a dict of user attributes""" + """ + The user attributes. + + :return: en empty :class:`dict`. + :rtype: dict + """ return {} class TestAuthUser(AuthUser): - """A test authentication class with one user test having - alose test as password and some attributes""" + """ + A test authentication class only working for one unique user. - def __init__(self, username): - super(TestAuthUser, self).__init__(username) + :param unicode username: A username, stored in the :attr:`username` + class attribute. The uniq valid value is ``settings.CAS_TEST_USER``. + """ def test_password(self, password): - """test `password` agains the user""" + """ + 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 equal to ``settings.CAS_TEST_PASSWORD``, ``False`` otherwise. + :rtype: bool + """ return self.username == settings.CAS_TEST_USER and password == settings.CAS_TEST_PASSWORD def attributs(self): - """return a dict of user attributes""" - return settings.CAS_TEST_ATTRIBUTES + """ + The user attributes. + + :return: the ``settings.CAS_TEST_ATTRIBUTES`` :class:`dict` if + :attr:`username` is valid, an empty :class:`dict` otherwise. + :rtype: dict + """ + if self.username == settings.CAS_TEST_USER: + return settings.CAS_TEST_ATTRIBUTES + else: # pragma: no cover (should not happen) + return {} class MysqlAuthUser(AuthUser): # pragma: no cover - """A mysql auth class: authentication user agains a mysql database""" + """ + A mysql authentication class: authentication 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): + # see the connect function at + # http://mysql-python.sourceforge.net/MySQLdb.html#functions-and-attributes + # for possible mysql config parameters. mysql_config = { "user": settings.CAS_SQL_USERNAME, "passwd": settings.CAS_SQL_PASSWORD, @@ -94,7 +151,14 @@ class MysqlAuthUser(AuthUser): # pragma: no cover super(MysqlAuthUser, self).__init__(username) def test_password(self, password): - """test `password` agains the user""" + """ + 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 check_password( settings.CAS_SQL_PASSWORD_CHECK, @@ -106,7 +170,14 @@ class MysqlAuthUser(AuthUser): # pragma: no cover return False def attributs(self): - """return a dict of user attributes""" + """ + 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: @@ -114,7 +185,14 @@ class MysqlAuthUser(AuthUser): # pragma: no cover class DjangoAuthUser(AuthUser): # pragma: no cover - """A django auth class: authenticate user agains django internal users""" + """ + A django auth class: authenticate user agains django internal users + + :param unicode username: A username, stored in the :attr:`username` + class attribute. Valid value are usernames of django internal users. + """ + #: a django user object if the username is found. The user model is retreived + #: using :func:`django.contrib.auth.get_user_model`. user = None def __init__(self, username): @@ -126,14 +204,27 @@ class DjangoAuthUser(AuthUser): # pragma: no cover super(DjangoAuthUser, self).__init__(username) def test_password(self, password): - """test `password` agains the user""" + """ + Tests ``password`` agains the user password. + + :param unicode password: a clear text password as submited by the user. + :return: ``True`` if :attr:`user` is valid and ``password`` is + correct, ``False`` otherwise. + :rtype: bool + """ if self.user: return self.user.check_password(password) else: return False def attributs(self): - """return a dict of user attributes""" + """ + The user attributes, defined as the fields on the :attr:`user` object. + + :return: a :class:`dict` with the :attr:`user` object fields. Attributes may be + If the user do not exists, the returned :class:`dict` is empty. + :rtype: dict + """ if self.user: attr = {} for field in self.user._meta.fields: @@ -144,7 +235,16 @@ class DjangoAuthUser(AuthUser): # pragma: no cover class CASFederateAuth(AuthUser): - """Authentication class used then CAS_FEDERATE is True""" + """ + Authentication class used then CAS_FEDERATE is True + + :param unicode username: A username, stored in the :attr:`username` + class attribute. Valid value are usernames of + :class:`FederatedUser` object. + :class:`FederatedUser` object are created on CAS + backends successful ticket validation. + """ + #: a :class`FederatedUser` object if ``username`` is found. user = None def __init__(self, username): @@ -157,7 +257,17 @@ class CASFederateAuth(AuthUser): super(CASFederateAuth, self).__init__(username) def test_password(self, ticket): - """test `password` agains the user""" + """ + Tests ``password`` agains the user password. + + :param unicode password: The CAS tickets just used to validate the user authentication + against its CAS backend. + :return: ``True`` if :attr:`user` is valid and ``password`` is + a ticket validated less than ``settings.CAS_TICKET_VALIDITY`` secondes and has not + being previously used for authenticated this + :class:`FederatedUser`. ``False`` otherwise. + :rtype: bool + """ if not self.user or not self.user.ticket: return False else: @@ -168,7 +278,13 @@ class CASFederateAuth(AuthUser): ) def attributs(self): - """return a dict of user attributes""" + """ + The user attributes, as returned by the CAS backend. + + :return: :obj:`FederatedUser.attributs`. + If the user do not exists, the returned :class:`dict` is empty. + :rtype: dict + """ if not self.user: # pragma: no cover (should not happen) return {} else: diff --git a/cas_server/cas.py b/cas_server/cas.py index 9eec396..2c5178e 100644 --- a/cas_server/cas.py +++ b/cas_server/cas.py @@ -36,7 +36,7 @@ class CASError(ValueError): class ReturnUnicode(object): @staticmethod - def unicode(string, charset): + def u(string, charset): if not isinstance(string, six.text_type): return string.decode(charset) else: @@ -157,7 +157,7 @@ class CASClientV1(CASClientBase, ReturnUnicode): charset = content_type.split("charset=")[-1] else: charset = "ascii" - user = self.unicode(page.readline().strip(), charset) + user = self.u(page.readline().strip(), charset) return user, None, None else: return None, None, None @@ -202,18 +202,18 @@ class CASClientV2(CASClientBase, ReturnUnicode): def parse_attributes_xml_element(cls, element, charset): attributes = dict() for attribute in element: - tag = cls.self.unicode(attribute.tag, charset).split(u"}").pop() + tag = cls.self.u(attribute.tag, charset).split(u"}").pop() if tag in attributes: if isinstance(attributes[tag], list): - attributes[tag].append(cls.unicode(attribute.text, charset)) + attributes[tag].append(cls.u(attribute.text, charset)) else: attributes[tag] = [attributes[tag]] - attributes[tag].append(cls.unicode(attribute.text, charset)) + attributes[tag].append(cls.u(attribute.text, charset)) else: if tag == u'attraStyle': pass else: - attributes[tag] = cls.unicode(attribute.text, charset) + attributes[tag] = cls.u(attribute.text, charset) return attributes @classmethod @@ -238,9 +238,9 @@ class CASClientV2(CASClientBase, ReturnUnicode): if tree[0].tag.endswith('authenticationSuccess'): for element in tree[0]: if element.tag.endswith('user'): - user = cls.unicode(element.text, charset) + user = cls.u(element.text, charset) elif element.tag.endswith('proxyGrantingTicket'): - pgtiou = cls.unicode(element.text, charset) + pgtiou = cls.u(element.text, charset) elif element.tag.endswith('attributes'): attributes = cls.parse_attributes_xml_element(element, charset) return user, attributes, pgtiou @@ -255,15 +255,15 @@ class CASClientV3(CASClientV2, SingleLogoutMixin): def parse_attributes_xml_element(cls, element, charset): attributes = dict() for attribute in element: - tag = cls.unicode(attribute.tag, charset).split(u"}").pop() + tag = cls.u(attribute.tag, charset).split(u"}").pop() if tag in attributes: if isinstance(attributes[tag], list): - attributes[tag].append(cls.unicode(attribute.text, charset)) + attributes[tag].append(cls.u(attribute.text, charset)) else: attributes[tag] = [attributes[tag]] - attributes[tag].append(cls.unicode(attribute.text, charset)) + attributes[tag].append(cls.u(attribute.text, charset)) else: - attributes[tag] = cls.unicode(attribute.text, charset) + attributes[tag] = cls.u(attribute.text, charset) return attributes @classmethod @@ -323,25 +323,25 @@ class CASClientWithSAMLV1(CASClientV2, SingleLogoutMixin): # User is validated name_identifier = tree.find('.//' + SAML_1_0_ASSERTION_NS + 'NameIdentifier') if name_identifier is not None: - user = self.unicode(name_identifier.text, charset) + user = self.u(name_identifier.text, charset) attrs = tree.findall('.//' + SAML_1_0_ASSERTION_NS + 'Attribute') for at in attrs: if self.username_attribute in list(at.attrib.values()): - user = self.unicode( + user = self.u( at.find(SAML_1_0_ASSERTION_NS + 'AttributeValue').text, charset ) attributes[u'uid'] = user values = at.findall(SAML_1_0_ASSERTION_NS + 'AttributeValue') - key = self.unicode(at.attrib['AttributeName'], charset) + key = self.u(at.attrib['AttributeName'], charset) if len(values) > 1: values_array = [] for v in values: - values_array.append(self.unicode(v.text, charset)) + values_array.append(self.u(v.text, charset)) attributes[key] = values_array else: - attributes[key] = self.unicode(values[0].text, charset) + attributes[key] = self.u(values[0].text, charset) return user, attributes, None finally: page.close() diff --git a/cas_server/default_settings.py b/cas_server/default_settings.py index 0b24f62..c7b2b12 100644 --- a/cas_server/default_settings.py +++ b/cas_server/default_settings.py @@ -13,84 +13,146 @@ from django.conf import settings from django.contrib.staticfiles.templatetags.staticfiles import static +from importlib import import_module -def setting_default(name, default_value): - """if the config `name` is not set, set it the `default_value`""" + +#: URL to the logo showed in the up left corner on the default templates. +CAS_LOGO_URL = static("cas_server/logo.png") +#: URLs to css and javascript external components. +CAS_COMPONENT_URLS = { + "bootstrap3_css": "//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css", + "bootstrap3_js": "//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/js/bootstrap.min.js", + "html5shiv": "//oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js", + "respond": "//oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js", + "jquery": "//code.jquery.com/jquery.min.js", +} +#: Path to the template showed on /login then the user is not autenticated. +CAS_LOGIN_TEMPLATE = 'cas_server/login.html' +#: Path to the template showed on /login?service=... then the user is authenticated and has asked +#: to be warned before being connected to a service. +CAS_WARN_TEMPLATE = 'cas_server/warn.html' +#: Path to the template showed on /login then to user is authenticated. +CAS_LOGGED_TEMPLATE = 'cas_server/logged.html' +#: Path to the template showed on /logout then to user is being disconnected. +CAS_LOGOUT_TEMPLATE = 'cas_server/logout.html' +#: Should we redirect users to /login after they logged out instead of displaying +#: :obj:`CAS_LOGOUT_TEMPLATE`. +CAS_REDIRECT_TO_LOGIN_AFTER_LOGOUT = False + + +#: A dotted path to a class or a class implementing cas_server.auth.AuthUser. +CAS_AUTH_CLASS = 'cas_server.auth.DjangoAuthUser' +#: Path to certificate authorities file. Usually on linux the local CAs are in +#: /etc/ssl/certs/ca-certificates.crt. ``True`` tell requests to use its internal certificat +#: authorities. +CAS_PROXY_CA_CERTIFICATE_PATH = True +#: Maximum number of parallel single log out requests send +#: if more requests need to be send, there are queued +CAS_SLO_MAX_PARALLEL_REQUESTS = 10 +#: Timeout for a single SLO request in seconds. +CAS_SLO_TIMEOUT = 5 +#: Shared to transmit then using the view :class:`cas_server.views.Auth` +CAS_AUTH_SHARED_SECRET = '' + + +#: Number of seconds the service tickets and proxy tickets are valid. This is the maximal time +#: between ticket issuance by the CAS and ticket validation by an application. +CAS_TICKET_VALIDITY = 60 +#: Number of seconds the proxy granting tickets are valid. +CAS_PGT_VALIDITY = 3600 +#: Number of seconds a ticket is kept in the database before sending Single Log Out request and +#: being cleared. +CAS_TICKET_TIMEOUT = 24*3600 + + +#: All CAS implementation MUST support ST and PT up to 32 chars, +#: PGT and PGTIOU up to 64 chars and it is RECOMMENDED that all +#: tickets up to 256 chars are supports so we use 64 for the default +#: len. +CAS_TICKET_LEN = 64 + +#: alias of :obj:`settings.CAS_TICKET_LEN` +CAS_LT_LEN = getattr(settings, 'CAS_TICKET_LEN', CAS_TICKET_LEN) +#: alias of :obj:`settings.CAS_TICKET_LEN` +#: Services MUST be able to accept service tickets of up to 32 characters in length. +CAS_ST_LEN = getattr(settings, 'CAS_TICKET_LEN', CAS_TICKET_LEN) +#: alias of :obj:`settings.CAS_TICKET_LEN` +#: Back-end services MUST be able to accept proxy tickets of up to 32 characters. +CAS_PT_LEN = getattr(settings, 'CAS_TICKET_LEN', CAS_TICKET_LEN) +#: alias of :obj:`settings.CAS_TICKET_LEN` +#: Services MUST be able to handle proxy-granting tickets of up to 64 +CAS_PGT_LEN = getattr(settings, 'CAS_TICKET_LEN', CAS_TICKET_LEN) +#: alias of :obj:`settings.CAS_TICKET_LEN` +#: Services MUST be able to handle PGTIOUs of up to 64 characters in length. +CAS_PGTIOU_LEN = getattr(settings, 'CAS_TICKET_LEN', CAS_TICKET_LEN) + +#: Prefix of login tickets. +CAS_LOGIN_TICKET_PREFIX = u'LT' +#: Prefix of service tickets. Service tickets MUST begin with the characters ST so you should not +#: change this. +CAS_SERVICE_TICKET_PREFIX = u'ST' +#: Prefix of proxy ticket. Proxy tickets SHOULD begin with the characters, PT. +CAS_PROXY_TICKET_PREFIX = u'PT' +#: Prefix of proxy granting ticket. Proxy-granting tickets SHOULD begin with the characters PGT. +CAS_PROXY_GRANTING_TICKET_PREFIX = u'PGT' +#: Prefix of proxy granting ticket IOU. Proxy-granting ticket IOUs SHOULD begin with the characters +#: PGTIOU. +CAS_PROXY_GRANTING_TICKET_IOU_PREFIX = u'PGTIOU' + + +#: Host for the SQL server. +CAS_SQL_HOST = 'localhost' +#: Username for connecting to the SQL server. +CAS_SQL_USERNAME = '' +#: Password for connecting to the SQL server. +CAS_SQL_PASSWORD = '' +#: Database name. +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' +#: 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 + + +#: Username of the test user. +CAS_TEST_USER = 'test' +#: Password of the test user. +CAS_TEST_PASSWORD = 'test' +#: Attributes of the test user. +CAS_TEST_ATTRIBUTES = { + 'nom': 'Nymous', + 'prenom': 'Ano', + 'email': 'anonymous@example.net', + 'alias': ['demo1', 'demo2'] +} + + +#: A :class:`bool` for activatinc the hability to fetch tickets using javascript. +CAS_ENABLE_AJAX_AUTH = False + + +#: A :class:`bool` for activating the federated mode +CAS_FEDERATE = False +#: Time after witch the cookie use for “remember my identity provider” expire (one week). +CAS_FEDERATE_REMEMBER_TIMEOUT = 604800 + +GLOBALS = globals().copy() +for name, default_value in GLOBALS.items(): + # get the current setting value, falling back to default_value value = getattr(settings, name, default_value) + # set the setting value to its value if defined, ellse to the default_value. setattr(settings, name, value) -setting_default('CAS_LOGO_URL', static("cas_server/logo.png")) - -setting_default('CAS_LOGIN_TEMPLATE', 'cas_server/login.html') -setting_default('CAS_WARN_TEMPLATE', 'cas_server/warn.html') -setting_default('CAS_LOGGED_TEMPLATE', 'cas_server/logged.html') -setting_default('CAS_LOGOUT_TEMPLATE', 'cas_server/logout.html') -setting_default('CAS_AUTH_CLASS', 'cas_server.auth.DjangoAuthUser') -# All CAS implementation MUST support ST and PT up to 32 chars, -# PGT and PGTIOU up to 64 chars and it is RECOMMENDED that all -# tickets up to 256 chars are supports so we use 64 for the default -# len. -setting_default('CAS_TICKET_LEN', 64) - -setting_default('CAS_LT_LEN', settings.CAS_TICKET_LEN) -setting_default('CAS_ST_LEN', settings.CAS_TICKET_LEN) -setting_default('CAS_PT_LEN', settings.CAS_TICKET_LEN) -setting_default('CAS_PGT_LEN', settings.CAS_TICKET_LEN) -setting_default('CAS_PGTIOU_LEN', settings.CAS_TICKET_LEN) - -setting_default('CAS_TICKET_VALIDITY', 60) -setting_default('CAS_PGT_VALIDITY', 3600) -setting_default('CAS_TICKET_TIMEOUT', 24*3600) -setting_default('CAS_PROXY_CA_CERTIFICATE_PATH', True) -setting_default('CAS_REDIRECT_TO_LOGIN_AFTER_LOGOUT', False) - -setting_default('CAS_AUTH_SHARED_SECRET', '') - -setting_default('CAS_LOGIN_TICKET_PREFIX', 'LT') -# Service tickets MUST begin with the characters ST so you should not change this -# Services MUST be able to accept service tickets of up to 32 characters in length -setting_default('CAS_SERVICE_TICKET_PREFIX', 'ST') -# Proxy tickets SHOULD begin with the characters, PT. -# Back-end services MUST be able to accept proxy tickets of up to 32 characters. -setting_default('CAS_PROXY_TICKET_PREFIX', 'PT') -# Proxy-granting tickets SHOULD begin with the characters PGT -# Services MUST be able to handle proxy-granting tickets of up to 64 -setting_default('CAS_PROXY_GRANTING_TICKET_PREFIX', 'PGT') -# Proxy-granting ticket IOUs SHOULD begin with the characters, PGTIOU -# Services MUST be able to handle PGTIOUs of up to 64 characters in length. -setting_default('CAS_PROXY_GRANTING_TICKET_IOU_PREFIX', 'PGTIOU') - -# Maximum number of parallel single log out requests send -# if more requests need to be send, there are queued -setting_default('CAS_SLO_MAX_PARALLEL_REQUESTS', 10) -# SLO request timeout. -setting_default('CAS_SLO_TIMEOUT', 5) - -setting_default('CAS_SQL_HOST', 'localhost') -setting_default('CAS_SQL_USERNAME', '') -setting_default('CAS_SQL_PASSWORD', '') -setting_default('CAS_SQL_DBNAME', '') -setting_default('CAS_SQL_DBCHARSET', 'utf8') -setting_default('CAS_SQL_USER_QUERY', 'SELECT user AS usersame, pass AS ' - 'password, users.* FROM users WHERE user = %s') -setting_default('CAS_SQL_PASSWORD_CHECK', 'crypt') # crypt or plain - -setting_default('CAS_TEST_USER', 'test') -setting_default('CAS_TEST_PASSWORD', 'test') -setting_default( - 'CAS_TEST_ATTRIBUTES', - { - 'nom': 'Nymous', - 'prenom': 'Ano', - 'email': 'anonymous@example.net', - 'alias': ['demo1', 'demo2'] - } -) - -setting_default('CAS_ENABLE_AJAX_AUTH', False) - -setting_default('CAS_FEDERATE', False) -setting_default('CAS_FEDERATE_REMEMBER_TIMEOUT', 604800) # one week +# if the federated mode is enabled, we must use the :class`cas_server.auth.CASFederateAuth` auth +# backend. if settings.CAS_FEDERATE: settings.CAS_AUTH_CLASS = "cas_server.auth.CASFederateAuth" + + +#: SessionStore class depending of :django:setting:`SESSION_ENGINE` +SessionStore = import_module(settings.SESSION_ENGINE).SessionStore diff --git a/cas_server/federate.py b/cas_server/federate.py index 74528cb..2cfd90e 100644 --- a/cas_server/federate.py +++ b/cas_server/federate.py @@ -10,26 +10,37 @@ # # (c) 2016 Valentin Samir """federated mode helper classes""" -from .default_settings import settings +from .default_settings import SessionStore from django.db import IntegrityError from .cas import CASClient from .models import FederatedUser, FederateSLO, User import logging -from importlib import import_module from six.moves import urllib -SessionStore = import_module(settings.SESSION_ENGINE).SessionStore - +#: logger facility logger = logging.getLogger(__name__) class CASFederateValidateUser(object): - """Class CAS client used to authenticate the user again a CAS provider""" + """ + Class CAS client used to authenticate the user again a CAS provider + + :param cas_server.models.FederatedIendityProvider provider: The provider to use for + authenticate the user. + :param unicode service_url: The service url to transmit to the ``provider``. + """ + #: the provider returned username username = None + #: the provider returned attributes attributs = {} + #: the CAS client instance client = None + #: the provider returned username this the provider suffix appended + federated_username = None + #: the identity provider + provider = None def __init__(self, provider, service_url): self.provider = provider @@ -41,15 +52,31 @@ class CASFederateValidateUser(object): ) def get_login_url(self): - """return the CAS provider login url""" + """ + :return: the CAS provider login url + :rtype: unicode + """ return self.client.get_login_url() def get_logout_url(self, redirect_url=None): - """return the CAS provider logout url""" + """ + :param redirect_url: The url to redirect to after logout from the provider, if provided. + :type redirect_url: :obj:`unicode` or :obj:`NoneType` + :return: the CAS provider logout url + :rtype: unicode + """ return self.client.get_logout_url(redirect_url) def verify_ticket(self, ticket): - """test `ticket` agains the CAS provider, if valid, create the local federated user""" + """ + test ``ticket`` agains the CAS provider, if valid, create a + :class:`FederatedUser` matching provider returned + username and attributes. + + :param unicode ticket: The ticket to validate against the provider CAS + :return: ``True`` if the validation succeed, else ``False``. + :rtype: bool + """ try: username, attributs = self.client.verify_ticket(ticket)[:2] except urllib.error.URLError: @@ -57,7 +84,7 @@ class CASFederateValidateUser(object): if username is not None: if attributs is None: attributs = {} - attributs["provider"] = self.provider + attributs["provider"] = self.provider.suffix self.username = username self.attributs = attributs user = FederatedUser.objects.update_or_create( @@ -73,7 +100,15 @@ class CASFederateValidateUser(object): @staticmethod def register_slo(username, session_key, ticket): - """association a ticket with a (username, session) for processing later SLO request""" + """ + association a ``ticket`` with a (``username``, ``session_key``) for processing later SLO + request by creating a :class:`cas_server.models.FederateSLO` object. + + :param unicode username: A logged user username, with the ``@`` component. + :param unicode session_key: A logged user session_key matching ``username``. + :param unicode ticket: A ticket used to authentication ``username`` for the session + ``session_key``. + """ try: FederateSLO.objects.create( username=username, @@ -84,7 +119,14 @@ class CASFederateValidateUser(object): pass def clean_sessions(self, logout_request): - """process a SLO request""" + """ + process a SLO request: Search for ticket values in ``logout_request``. For each + ticket value matching a :class:`cas_server.models.FederateSLO`, disconnect the + corresponding user. + + :param unicode logout_request: An XML document contening one or more Single Log Out + requests. + """ try: slos = self.client.get_saml_slos(logout_request) or [] except NameError: # pragma: no cover (should not happen) diff --git a/cas_server/forms.py b/cas_server/forms.py index 5284fac..03c7515 100644 --- a/cas_server/forms.py +++ b/cas_server/forms.py @@ -18,21 +18,55 @@ import cas_server.utils as utils import cas_server.models as models -class WarnForm(forms.Form): - """Form used on warn page before emiting a ticket""" +class BootsrapForm(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(): + # 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: + attrs['class'] = "form-control" + if field.label: + attrs["placeholder"] = field.label + if field.required: + attrs["required"] = "required" + field.widget.attrs.update(attrs) + + +class WarnForm(BootsrapForm): + """ + Bases: :class:`django.forms.Form` + + Form used on warn page before emiting a ticket + """ + + #: The service url for which the user want a ticket service = 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) + #: ``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(forms.Form): +class FederateSelect(BootsrapForm): """ - Form used on the login page when CAS_FEDERATE is True - allowing the user to choose a identity provider. + Bases: :class:`django.forms.Form` + + Form used on the login page when ``settings.CAS_FEDERATE`` is ``True`` + allowing the user to choose an identity provider. """ + #: The providers the user can choose to be used as authentication backend provider = forms.ModelChoiceField( queryset=models.FederatedIendityProvider.objects.filter(display=True).order_by( "pos", @@ -42,27 +76,49 @@ class FederateSelect(forms.Form): 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 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(forms.Form): - """Form used on the login page to retrive user credentials""" +class UserCredential(BootsrapForm): + """ + Bases: :class:`django.forms.Form` + + 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) + #: 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) def clean(self): + """ + Validate that the submited :attr:`username` and :attr:`password` are valid + + :raises django.forms.ValidationError: if the :attr:`username` and :attr:`password` + are not valid. + :return: The cleaned POST data + :rtype: dict + """ cleaned_data = super(UserCredential, self).clean() auth = utils.import_attr(settings.CAS_AUTH_CLASS)(cleaned_data.get("username")) if auth.test_password(cleaned_data.get("password")): @@ -73,17 +129,51 @@ class UserCredential(forms.Form): class FederateUserCredential(UserCredential): - """Form used on the login page to retrive user credentials""" + """ + Bases: :class:`UserCredential` + + Form used on a auto submited page for linking the views + :class:`FederateAuth` and + :class:`LoginView`. + + On successful authentication on a provider, in the view + :class:`FederateAuth` a + :class:`FederatedUser` is created by + :meth:`cas_server.federate.CASFederateValidateUser.verify_ticket` and the user is redirected + to :class:`LoginView`. This form is then automatically filled + with infos matching the created :class:`FederatedUser` + using the ``ticket`` as one time password and submited using javascript. If javascript is + not enabled, a connect button is displayed. + + 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 clean(self): + """ + Validate that the submited :attr:`username` and :attr:`password` are valid using + the :class:`CASFederateAuth` auth class. + + :raises django.forms.ValidationError: if the :attr:`username` and :attr:`password` + do not correspond to a :class:`FederatedUser`. + :return: The cleaned POST data + :rtype: dict + """ cleaned_data = super(FederateUserCredential, self).clean() try: user = models.FederatedUser.get_from_federated_username(cleaned_data["username"]) @@ -99,7 +189,11 @@ class FederateUserCredential(UserCredential): class TicketForm(forms.ModelForm): - """Form for Tickets in the admin interface""" + """ + Bases: :class:`django.forms.ModelForm` + + Form for Tickets in the admin interface + """ class Meta: model = models.Ticket exclude = [] diff --git a/cas_server/migrations/0001_squashed_0021_auto_20150611_2102.py b/cas_server/migrations/0001_squashed_0021_auto_20150611_2102.py index a2000bc..c3d3785 100644 --- a/cas_server/migrations/0001_squashed_0021_auto_20150611_2102.py +++ b/cas_server/migrations/0001_squashed_0021_auto_20150611_2102.py @@ -4,7 +4,6 @@ from __future__ import unicode_literals from django.db import models, migrations import django.db.models.deletion import cas_server.utils -import picklefield.fields class Migration(migrations.Migration): @@ -31,7 +30,7 @@ class Migration(migrations.Migration): name='ProxyGrantingTicket', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('attributs', picklefield.fields.PickledObjectField(editable=False)), + ('attributs', models.TextField(blank=True, default=None, null=True)), ('validate', models.BooleanField(default=False)), ('service', models.TextField()), ('creation', models.DateTimeField(auto_now_add=True)), @@ -47,7 +46,7 @@ class Migration(migrations.Migration): name='ProxyTicket', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('attributs', picklefield.fields.PickledObjectField(editable=False)), + ('attributs', models.TextField(blank=True, default=None, null=True)), ('validate', models.BooleanField(default=False)), ('service', models.TextField()), ('creation', models.DateTimeField(auto_now_add=True)), @@ -80,7 +79,7 @@ class Migration(migrations.Migration): name='ServiceTicket', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('attributs', picklefield.fields.PickledObjectField(editable=False)), + ('attributs', models.TextField(blank=True, default=None, null=True)), ('validate', models.BooleanField(default=False)), ('service', models.TextField()), ('creation', models.DateTimeField(auto_now_add=True)), diff --git a/cas_server/migrations/0005_auto_20160616_1018.py b/cas_server/migrations/0005_auto_20160616_1018.py index fea9167..8d361b9 100644 --- a/cas_server/migrations/0005_auto_20160616_1018.py +++ b/cas_server/migrations/0005_auto_20160616_1018.py @@ -3,7 +3,6 @@ from __future__ import unicode_literals from django.db import migrations, models -import picklefield.fields import django.db.models.deletion @@ -41,7 +40,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('username', models.CharField(max_length=124)), ('provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cas_server.FederatedIendityProvider')), - ('attributs', picklefield.fields.PickledObjectField(editable=False)), + ('attributs', models.TextField(blank=True, default=None, null=True)), ('ticket', models.CharField(max_length=255)), ('last_update', models.DateTimeField(auto_now=True)), ], diff --git a/cas_server/migrations/0006_auto_20160706_1727.py b/cas_server/migrations/0006_auto_20160706_1727.py new file mode 100644 index 0000000..0a30642 --- /dev/null +++ b/cas_server/migrations/0006_auto_20160706_1727.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-07-06 17:27 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cas_server', '0005_auto_20160616_1018'), + ] + + operations = [ + migrations.AlterField( + model_name='federatediendityprovider', + name='cas_protocol_version', + field=models.CharField(choices=[(b'1', b'CAS 1.0'), (b'2', b'CAS 2.0'), (b'3', b'CAS 3.0'), (b'CAS_2_SAML_1_0', b'SAML 1.1')], default=b'3', help_text='Version of the CAS protocol to use when sending requests the the backend CAS.', max_length=30, verbose_name='CAS protocol version'), + ), + migrations.AlterField( + model_name='federatediendityprovider', + name='display', + field=models.BooleanField(default=True, help_text='Display the provider on the login page.', verbose_name='display'), + ), + migrations.AlterField( + model_name='federatediendityprovider', + name='pos', + field=models.IntegerField(default=100, help_text='Position of the identity provider on the login page. Identity provider are sorted using the (position, verbose name, suffix) attributes.', verbose_name='position'), + ), + migrations.AlterField( + model_name='federatediendityprovider', + name='suffix', + field=models.CharField(help_text='Suffix append to backend CAS returner username: ``returned_username`` @ ``suffix``.', max_length=30, unique=True, verbose_name='suffix'), + ), + migrations.AlterField( + model_name='federatediendityprovider', + name='verbose_name', + field=models.CharField(help_text='Name for this identity provider displayed on the login page.', max_length=255, verbose_name='verbose name'), + ), + ] diff --git a/cas_server/migrations/0007_auto_20160723_2252.py b/cas_server/migrations/0007_auto_20160723_2252.py new file mode 100644 index 0000000..fd0c8a1 --- /dev/null +++ b/cas_server/migrations/0007_auto_20160723_2252.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.8 on 2016-07-23 22:52 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cas_server', '0006_auto_20160706_1727'), + ] + + operations = [ + migrations.RemoveField( + model_name='federateduser', + name='attributs', + ), + migrations.RemoveField( + model_name='proxygrantingticket', + name='attributs', + ), + migrations.RemoveField( + model_name='proxyticket', + name='attributs', + ), + migrations.RemoveField( + model_name='serviceticket', + name='attributs', + ), + migrations.AddField( + model_name='federateduser', + name='_attributs', + field=models.TextField(blank=True, default=None, null=True), + ), + migrations.AddField( + model_name='proxygrantingticket', + name='_attributs', + field=models.TextField(blank=True, default=None, null=True), + ), + migrations.AddField( + model_name='proxyticket', + name='_attributs', + field=models.TextField(blank=True, default=None, null=True), + ), + migrations.AddField( + model_name='serviceticket', + name='_attributs', + field=models.TextField(blank=True, default=None, null=True), + ), + migrations.AlterField( + model_name='federatediendityprovider', + name='suffix', + field=models.CharField(help_text='Suffix append to backend CAS returned username: ``returned_username`` @ ``suffix``.', max_length=30, unique=True, verbose_name='suffix'), + ), + ] diff --git a/cas_server/models.py b/cas_server/models.py index 2314c4f..6e87d40 100644 --- a/cas_server/models.py +++ b/cas_server/models.py @@ -10,7 +10,7 @@ # # (c) 2015-2016 Valentin Samir """models for the app""" -from .default_settings import settings +from .default_settings import settings, SessionStore from django.db import models from django.db.models import Q @@ -18,36 +18,46 @@ 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 picklefield.fields import PickledObjectField import re import sys import logging -from importlib import import_module from datetime import timedelta from concurrent.futures import ThreadPoolExecutor from requests_futures.sessions import FuturesSession import cas_server.utils as utils -SessionStore = import_module(settings.SESSION_ENGINE).SessionStore - +#: logger facility logger = logging.getLogger(__name__) @python_2_unicode_compatible class FederatedIendityProvider(models.Model): - """An identity provider for the federated mode""" + """ + Bases: :class:`django.db.models.Model` + + An identity provider for the federated mode + """ class Meta: - verbose_name = _("identity provider") - verbose_name_plural = _("identity providers") + verbose_name = _(u"identity provider") + verbose_name_plural = _(u"identity providers") + #: Suffix append to backend CAS returned username: ``returned_username`` @ ``suffix``. + #: it must be unique. suffix = models.CharField( max_length=30, unique=True, verbose_name=_(u"suffix"), - help_text=_("Suffix append to backend CAS returner username: `returned_username`@`suffix`") + help_text=_( + u"Suffix append to backend CAS returned " + u"username: ``returned_username`` @ ``suffix``." + ) ) + #: URL to the root of the CAS server application. If login page is + #: https://cas.example.net/cas/login then :attr:`server_url` should be + #: https://cas.example.net/cas/ server_url = models.CharField(max_length=255, verbose_name=_(u"server url")) + #: Version of the CAS protocol to use when sending requests the the backend CAS. cas_protocol_version = models.CharField( max_length=30, choices=[ @@ -57,28 +67,37 @@ class FederatedIendityProvider(models.Model): ("CAS_2_SAML_1_0", "SAML 1.1") ], verbose_name=_(u"CAS protocol version"), - help_text=_("Version of the CAS protocol to use when sending requests the the backend CAS"), + help_text=_( + u"Version of the CAS protocol to use when sending requests the the backend CAS." + ), default="3" ) + #: Name for this identity provider displayed on the login page. verbose_name = models.CharField( max_length=255, verbose_name=_(u"verbose name"), - help_text=_("Name for this identity provider displayed on the login page") + help_text=_(u"Name for this identity provider displayed on the login page.") ) + #: Position of the identity provider on the login page. Identity provider are sorted using the + #: (:attr:`pos`, :attr:`verbose_name`, :attr:`suffix`) attributes. pos = models.IntegerField( default=100, verbose_name=_(u"position"), help_text=_( ( + u"Position of the identity provider on the login page. " u"Identity provider are sorted using the " - u"(position, verbose name, suffix) attributes" + u"(position, verbose name, suffix) attributes." ) ) ) + #: Display the provider on the login page. Beware that this do not disable the identity + #: provider, it just hide it on the login page. User will always be able to log in using this + #: provider by fetching ``/federate/suffix``. display = models.BooleanField( default=True, verbose_name=_(u"display"), - help_text=_("Display the provider on the login page") + help_text=_("Display the provider on the login page.") ) def __str__(self): @@ -86,36 +105,72 @@ class FederatedIendityProvider(models.Model): @staticmethod def build_username_from_suffix(username, suffix): - """Transform backend username into federated username using `suffix`""" + """ + Transform backend username into federated username using ``suffix`` + + :param unicode username: A CAS backend returned username + :param unicode suffix: A suffix identifying the CAS backend + :return: The federated username: ``username`` @ ``suffix``. + :rtype: unicode + """ return u'%s@%s' % (username, suffix) def build_username(self, username): - """Transform backend username into federated username""" + """ + Transform backend username into federated username + + :param unicode username: A CAS backend returned username + :return: The federated username: ``username`` @ :attr:`suffix`. + :rtype: unicode + """ return u'%s@%s' % (username, self.suffix) @python_2_unicode_compatible class FederatedUser(models.Model): - """A federated user as returner by a CAS provider (username and attributes)""" + """ + Bases: :class:`django.db.models.Model` + + A federated user as returner by a CAS provider (username and attributes) + """ class Meta: unique_together = ("username", "provider") + #: The user username returned by the CAS backend on successful ticket validation username = models.CharField(max_length=124) + #: A foreign key to :class:`FederatedIendityProvider` provider = models.ForeignKey(FederatedIendityProvider, on_delete=models.CASCADE) - attributs = PickledObjectField() + #: The user attributes json encoded + _attributs = models.TextField(default=None, null=True, blank=True) + #: The last ticket used to authenticate :attr:`username` against :attr:`provider` ticket = models.CharField(max_length=255) + #: Last update timespampt. Usually, the last time :attr:`ticket` has been set. last_update = models.DateTimeField(auto_now=True) def __str__(self): return self.federated_username + @property + def attributs(self): + """The user attributes returned by the CAS backend on successful ticket validation""" + if self._attributs is not None: + return utils.json.loads(self._attributs) + + @attributs.setter + def attributs(self, value): + """attributs property setter""" + self._attributs = utils.json_encode(value) + @property def federated_username(self): - """return the federated username with a suffix""" + """The federated username with a suffix for the current :class:`FederatedUser`.""" return self.provider.build_username(self.username) @classmethod def get_from_federated_username(cls, username): - """return a FederatedUser object from a federated username""" + """ + :return: A :class:`FederatedUser` object from a federated ``username`` + :rtype: :class:`FederatedUser` + """ if username is None: raise cls.DoesNotExist() else: @@ -130,7 +185,7 @@ class FederatedUser(models.Model): @classmethod def clean_old_entries(cls): - """remove old unused federated users""" + """remove old unused :class:`FederatedUser`""" federated_users = cls.objects.filter( last_update__lt=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_TIMEOUT)) ) @@ -141,16 +196,23 @@ class FederatedUser(models.Model): class FederateSLO(models.Model): - """An association between a CAS provider ticket and a (username, session) for processing SLO""" + """ + Bases: :class:`django.db.models.Model` + + An association between a CAS provider ticket and a (username, session) for processing SLO + """ class Meta: unique_together = ("username", "session_key", "ticket") + #: the federated username with the ``@``component username = models.CharField(max_length=30) + #: the session key for the session :attr:`username` has been authenticated using :attr:`ticket` session_key = models.CharField(max_length=40, blank=True, null=True) + #: The ticket used to authenticate :attr:`username` ticket = models.CharField(max_length=255, db_index=True) @classmethod def clean_deleted_sessions(cls): - """remove old object for which the session do not exists anymore""" + """remove old :class:`FederateSLO` object for which the session do not exists anymore""" for federate_slo in cls.objects.all(): if not SessionStore(session_key=federate_slo.session_key).get('authenticated'): federate_slo.delete() @@ -158,17 +220,27 @@ class FederateSLO(models.Model): @python_2_unicode_compatible class User(models.Model): - """A user logged into the CAS""" + """ + Bases: :class:`django.db.models.Model` + + A user logged into the CAS + """ class Meta: unique_together = ("username", "session_key") verbose_name = _("User") verbose_name_plural = _("Users") + #: The session key of the current authenticated user session_key = models.CharField(max_length=40, blank=True, null=True) + #: The username of the current authenticated user username = models.CharField(max_length=30) + #: Last time the authenticated user has do something (auth, fetch ticket, etc…) date = models.DateTimeField(auto_now=True) def delete(self, *args, **kwargs): - """remove the User""" + """ + Remove the current :class:`User`. If ``settings.CAS_FEDERATE`` is ``True``, also delete + the corresponding :class:`FederateSLO` object. + """ if settings.CAS_FEDERATE: FederateSLO.objects.filter( username=self.username, @@ -178,7 +250,10 @@ class User(models.Model): @classmethod def clean_old_entries(cls): - """Remove users inactive since more that SESSION_COOKIE_AGE""" + """ + Remove :class:`User` objects inactive since more that + :django:setting:`SESSION_COOKIE_AGE` and send corresponding SingleLogOut requests. + """ users = cls.objects.filter( date__lt=(timezone.now() - timedelta(seconds=settings.SESSION_COOKIE_AGE)) ) @@ -188,7 +263,7 @@ class User(models.Model): @classmethod def clean_deleted_sessions(cls): - """Remove user where the session do not exists anymore""" + """Remove :class:`User` objects where the corresponding session do not exists anymore.""" for user in cls.objects.all(): if not SessionStore(session_key=user.session_key).get('authenticated'): user.logout() @@ -196,14 +271,22 @@ class User(models.Model): @property def attributs(self): - """return a fresh dict for the user attributs""" + """ + Property. + A fresh :class:`dict` for the user attributes, using ``settings.CAS_AUTH_CLASS`` + """ return utils.import_attr(settings.CAS_AUTH_CLASS)(self.username).attributs() def __str__(self): return u"%s - %s" % (self.username, self.session_key) def logout(self, request=None): - """Sending SLO request to all services the user logged in""" + """ + Send SLO requests to all services the user is logged in. + + :param request: The current django HttpRequest to display possible failure to the user. + :type request: :class:`django.http.HttpRequest` or :obj:`NoneType` + """ async_list = [] session = FuturesSession( executor=ThreadPoolExecutor(max_workers=settings.CAS_SLO_MAX_PARALLEL_REQUESTS) @@ -236,9 +319,22 @@ class User(models.Model): def get_ticket(self, ticket_class, service, service_pattern, renew): """ - Generate a ticket using `ticket_class` for the service - `service` matching `service_pattern` and asking or not for - authentication renewal with `renew + Generate a ticket using ``ticket_class`` for the service + ``service`` matching ``service_pattern`` and asking or not for + authentication renewal with ``renew`` + + :param type ticket_class: :class:`ServiceTicket` or :class:`ProxyTicket` or + :class:`ProxyGrantingTicket`. + :param unicode service: The service url for which we want a ticket. + :param ServicePattern service_pattern: The service pattern matching ``service``. + Beware that ``service`` must match :attr:`ServicePattern.pattern` and the current + :class:`User` must pass :meth:`ServicePattern.check_user`. These checks are not done + here and you must perform them before calling this method. + :param bool renew: Should be ``True`` if authentication has been renewed. Must be + ``False`` otherwise. + :return: A :class:`Ticket` object. + :rtype: :class:`ServiceTicket` or :class:`ProxyTicket` or + :class:`ProxyGrantingTicket`. """ attributs = dict( (a.name, a.replace if a.replace else a.name) for a in service_pattern.attributs.all() @@ -273,8 +369,20 @@ class User(models.Model): return ticket def get_service_url(self, service, service_pattern, renew): - """Return the url to which the user must be redirected to - after a Service Ticket has been generated""" + """ + Return the url to which the user must be redirected to + after a Service Ticket has been generated + + :param unicode service: The service url for which we want a ticket. + :param ServicePattern service_pattern: The service pattern matching ``service``. + Beware that ``service`` must match :attr:`ServicePattern.pattern` and the current + :class:`User` must pass :meth:`ServicePattern.check_user`. These checks are not done + here and you must perform them before calling this method. + :param bool renew: Should be ``True`` if authentication has been renewed. Must be + ``False`` otherwise. + :return unicode: The service url with the ticket GET param added. + :rtype: unicode + """ ticket = self.get_ticket(ServiceTicket, service, service_pattern, renew) url = utils.update_url(service, {'ticket': ticket.value}) logger.info("Service ticket created for service %s by user %s." % (service, self.username)) @@ -282,41 +390,60 @@ class User(models.Model): class ServicePatternException(Exception): - """Base exception of exceptions raised in the ServicePattern model""" + """ + Bases: :class:`exceptions.Exception` + + Base exception of exceptions raised in the ServicePattern model""" pass class BadUsername(ServicePatternException): - """Exception raised then an non allowed username - try to get a ticket for a service""" + """ + Bases: :class:`ServicePatternException` + + Exception raised then an non allowed username try to get a ticket for a service + """ pass class BadFilter(ServicePatternException): - """"Exception raised then a user try - to get a ticket for a service and do not reach a condition""" + """ + Bases: :class:`ServicePatternException` + + Exception raised then a user try to get a ticket for a service and do not reach a condition + """ pass class UserFieldNotDefined(ServicePatternException): - """Exception raised then a user try to get a ticket for a service - using as username an attribut not present on this user""" + """ + Bases: :class:`ServicePatternException` + + Exception raised then a user try to get a ticket for a service using as username + an attribut not present on this user + """ pass @python_2_unicode_compatible class ServicePattern(models.Model): - """Allowed services pattern agains services are tested to""" + """ + Bases: :class:`django.db.models.Model` + + Allowed services pattern agains services are tested to + """ class Meta: ordering = ("pos", ) verbose_name = _("Service pattern") verbose_name_plural = _("Services patterns") + #: service patterns are sorted using the :attr:`pos` attribute pos = models.IntegerField( default=100, verbose_name=_(u"position"), help_text=_(u"service patterns are sorted using the position attribute") ) + #: A name for the service (this can bedisplayed to the user on the login page) name = models.CharField( max_length=255, unique=True, @@ -325,6 +452,9 @@ class ServicePattern(models.Model): verbose_name=_(u"name"), help_text=_(u"A name for the service") ) + #: 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 '\\'. pattern = models.CharField( max_length=255, unique=True, @@ -335,6 +465,7 @@ 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 user_field = models.CharField( max_length=255, default="", @@ -342,27 +473,35 @@ class ServicePattern(models.Model): verbose_name=_(u"user field"), help_text=_("Name of the attribut to transmit as username, empty = login") ) + #: A boolean allowing to limit username allowed to connect to :attr:`usernames`. restrict_users = models.BooleanField( default=False, verbose_name=_(u"restrict username"), help_text=_("Limit username allowed to connect to the list provided bellow") ) + #: A boolean allowing to deliver :class:`ProxyTicket` to the service. proxy = models.BooleanField( default=False, verbose_name=_(u"proxy"), help_text=_("Proxy tickets can be delivered to the service") ) + #: A boolean allowing the service to be used as a proxy callback (via the pgtUrl GET param) + #: to deliver :class:`ProxyGrantingTicket`. proxy_callback = models.BooleanField( default=False, verbose_name=_(u"proxy callback"), help_text=_("can be used as a proxy callback to deliver PGT") ) + #: Enable SingleLogOut for the service. Old validaed tickets for the service will be kept + #: until ``settings.CAS_TICKET_TIMEOUT`` after what a SLO request is send to the service and + #: the ticket is purged from database. A SLO can be send earlier if the user log-out. single_log_out = models.BooleanField( default=False, verbose_name=_(u"single log out"), help_text=_("Enable SLO for the service") ) - + #: An URL where the SLO request will be POST. If empty the service url will be used. + #: This is usefull for non HTTP proxied services like smtp or imap. single_log_out_callback = models.CharField( max_length=255, default="", @@ -376,7 +515,20 @@ class ServicePattern(models.Model): return u"%s: %s" % (self.pos, self.pattern) def check_user(self, user): - """Check if `user` if allowed to use theses services""" + """ + Check if ``user`` if allowed to use theses services. If ``user`` is not allowed, + raises one of :class:`BadFilter`, :class:`UserFieldNotDefined`, :class:`BadUsername` + + :param User user: a :class:`User` object + :raises BadUsername: if :attr:`restrict_users` if ``True`` and :attr:`User.username` + is not within :attr:`usernames`. + :raises BadFilter: if a :class:`FilterAttributValue` condition of :attr:`filters` + connot be verified. + :raises UserFieldNotDefined: if :attr:`user_field` is defined and its value is not + within :attr:`User.attributs`. + :return: ``True`` + :rtype: bool + """ if self.restrict_users and not self.usernames.filter(value=user.username): logger.warning("Username %s not allowed on service %s" % (user.username, self.name)) raise BadUsername() @@ -416,8 +568,15 @@ class ServicePattern(models.Model): @classmethod def validate(cls, service): - """Check if a Service Patern match `service` and - return it, else raise `ServicePattern.DoesNotExist`""" + """ + Get a :class:`ServicePattern` intance from a service url. + + :param unicode service: A service url + :return: A :class:`ServicePattern` instance matching ``service``. + :rtype: :class:`ServicePattern` + :raises ServicePattern.DoesNotExist: if no :class:`ServicePattern` is matching + ``service``. + """ for service_pattern in cls.objects.all().order_by('pos'): if re.match(service_pattern.pattern, service): return service_pattern @@ -427,12 +586,20 @@ class ServicePattern(models.Model): @python_2_unicode_compatible class Username(models.Model): - """A list of allowed usernames on a service pattern""" + """ + Bases: :class:`django.db.models.Model` + + A list of allowed usernames on a :class:`ServicePattern` + """ + #: username allowed to connect to the service value = models.CharField( max_length=255, verbose_name=_(u"username"), help_text=_(u"username allowed to connect to the service") ) + #: ForeignKey to a :class:`ServicePattern`. :class:`Username` instances for a + #: :class:`ServicePattern` are accessible thought its :attr:`ServicePattern.usernames` + #: attribute. service_pattern = models.ForeignKey(ServicePattern, related_name="usernames") def __str__(self): @@ -441,14 +608,23 @@ class Username(models.Model): @python_2_unicode_compatible class ReplaceAttributName(models.Model): - """A list of replacement of attributs name for a service pattern""" + """ + Bases: :class:`django.db.models.Model` + + A replacement of an attribute name for a :class:`ServicePattern`. It also tell to transmit + an attribute of :attr:`User.attributs` to the service. An empty :attr:`replace` mean + to use the original attribute name. + """ class Meta: unique_together = ('name', 'replace', 'service_pattern') + #: Name the attribute: a key of :attr:`User.attributs` 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") ) + #: The name of the attribute to transmit to the service. If empty, the value of :attr:`name` + #: is used. replace = models.CharField( max_length=255, blank=True, @@ -456,6 +632,9 @@ class ReplaceAttributName(models.Model): help_text=_(u"name under which the attribut will be show" u"to the service. empty = default name of the attribut") ) + #: ForeignKey to a :class:`ServicePattern`. :class:`ReplaceAttributName` instances for a + #: :class:`ServicePattern` are accessible thought its :attr:`ServicePattern.attributs` + #: attribute. service_pattern = models.ForeignKey(ServicePattern, related_name="attributs") def __str__(self): @@ -467,17 +646,29 @@ class ReplaceAttributName(models.Model): @python_2_unicode_compatible class FilterAttributValue(models.Model): - """A list of filter on attributs for a service pattern""" + """ + Bases: :class:`django.db.models.Model` + + A filter on :attr:`User.attributs` for a :class:`ServicePattern`. If a :class:`User` do not + have an attribute :attr:`attribut` or its value do not match :attr:`pattern`, then + :meth:`ServicePattern.check_user` will raises :class:`BadFilter` if called with that user. + """ + #: 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") ) + #: 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. pattern = models.CharField( max_length=255, verbose_name=_(u"pattern"), help_text=_(u"a regular expression") ) + #: ForeignKey to a :class:`ServicePattern`. :class:`FilterAttributValue` instances for a + #: :class:`ServicePattern` are accessible thought its :attr:`ServicePattern.filters` + #: attribute. service_pattern = models.ForeignKey(ServicePattern, related_name="filters") def __str__(self): @@ -486,23 +677,34 @@ class FilterAttributValue(models.Model): @python_2_unicode_compatible class ReplaceAttributValue(models.Model): - """Replacement to apply on attributs values for a service pattern""" + """ + Bases: :class:`django.db.models.Model` + + A replacement (using a regular expression) of an attribute value for a + :class:`ServicePattern`. + """ + #: 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") ) + #: A regular expression matching the part of the attribute value that need to be changed pattern = models.CharField( max_length=255, verbose_name=_(u"pattern"), help_text=_(u"An regular expression maching whats need to be replaced") ) + #: The replacement to what is mached by :attr:`pattern`. groups are capture by \\1, \\2 … replace = models.CharField( max_length=255, blank=True, verbose_name=_(u"replace"), help_text=_(u"replace expression, groups are capture by \\1, \\2 …") ) + #: ForeignKey to a :class:`ServicePattern`. :class:`ReplaceAttributValue` instances for a + #: :class:`ServicePattern` are accessible thought its :attr:`ServicePattern.replacements` + #: attribute. service_pattern = models.ForeignKey(ServicePattern, related_name="replacements") def __str__(self): @@ -511,21 +713,54 @@ class ReplaceAttributValue(models.Model): @python_2_unicode_compatible class Ticket(models.Model): - """Generic class for a Ticket""" + """ + Bases: :class:`django.db.models.Model` + + Generic class for a Ticket + """ class Meta: abstract = True + #: ForeignKey to a :class:`User`. user = models.ForeignKey(User, related_name="%(class)s") - attributs = PickledObjectField() + #: The user attributes to transmit to the service json encoded + _attributs = models.TextField(default=None, null=True, blank=True) + #: A boolean. ``True`` if the ticket has been validated validate = models.BooleanField(default=False) + #: The service url for the ticket service = models.TextField() + #: ForeignKey to a :class:`ServicePattern`. The :class:`ServicePattern` corresponding to + #: :attr:`service`. Use :meth:`ServicePattern.validate` to find it. service_pattern = models.ForeignKey(ServicePattern, related_name="%(class)s") + #: Date of the ticket creation creation = models.DateTimeField(auto_now_add=True) + #: A boolean. ``True`` if the user has just renew his authentication renew = models.BooleanField(default=False) + #: A boolean. Set to :attr:`service_pattern` attribute + #: :attr:`ServicePattern.single_log_out` value. single_log_out = models.BooleanField(default=False) + #: Max duration between ticket creation and its validation. Any validation attempt for the + #: ticket after :attr:`creation` + VALIDITY will fail as if the ticket do not exists. VALIDITY = settings.CAS_TICKET_VALIDITY + #: Time we keep ticket with :attr:`single_log_out` set to ``True`` before sending SingleLogOut + #: requests. TIMEOUT = settings.CAS_TICKET_TIMEOUT + @property + def attributs(self): + """The user attributes to be transmited to the service on successful validation""" + if self._attributs is not None: + return utils.json.loads(self._attributs) + + @attributs.setter + def attributs(self, value): + """attributs property setter""" + self._attributs = utils.json_encode(value) + + class DoesNotExist(Exception): + """raised in :meth:`Ticket.get` then ticket prefix and ticket classes mismatch""" + pass + def __str__(self): return u"Ticket-%s" % self.pk @@ -596,16 +831,119 @@ class Ticket(models.Model): ) @staticmethod - def get_class(ticket): - for ticket_class in [ServiceTicket, ProxyTicket, ProxyGrantingTicket]: + def get_class(ticket, classes=None): + """ + Return the ticket class of ``ticket`` + + :param unicode ticket: A ticket + :param list classes: Optinal arguement. A list of possible :class:`Ticket` subclasses + :return: The class corresponding to ``ticket`` (:class:`ServiceTicket` or + :class:`ProxyTicket` or :class:`ProxyGrantingTicket`) if found among ``classes, + ``None`` otherwise. + :rtype: :obj:`type` or :obj:`NoneType` + """ + if classes is None: # pragma: no cover (not used) + classes = [ServiceTicket, ProxyTicket, ProxyGrantingTicket] + for ticket_class in classes: if ticket.startswith(ticket_class.PREFIX): return ticket_class + def username(self): + """ + The username to send on ticket validation + + :return: The value of the corresponding user attribute if + :attr:`service_pattern`.user_field is set, the user username otherwise. + """ + if self.service_pattern.user_field and self.user.attributs.get( + self.service_pattern.user_field + ): + username = self.user.attributs[self.service_pattern.user_field] + if isinstance(username, list): + # the list is not empty because we wont generate a ticket with a user_field + # that evaluate to False + username = username[0] + else: + username = self.user.username + return username + + def attributs_flat(self): + """ + generate attributes list for template rendering + + :return: An list of (attribute name, attribute value) of all user attributes flatened + (no nested list) + :rtype: :obj:`list` of :obj:`tuple` of :obj:`unicode` + """ + attributes = [] + for key, value in self.attributs.items(): + if isinstance(value, list): + for elt in value: + attributes.append((key, elt)) + else: + attributes.append((key, value)) + return attributes + + @classmethod + def get(cls, ticket, renew=False, service=None): + """ + Search the database for a valid ticket with provided arguments + + :param unicode ticket: A ticket value + :param bool renew: Is authentication renewal needed + :param unicode service: Optional argument. The ticket service + :raises Ticket.DoesNotExist: if no class is found for the ticket prefix + :raises cls.DoesNotExist: if ``ticket`` value is not found in th database + :return: a :class:`Ticket` instance + :rtype: Ticket + """ + # If the method class is the ticket abstract class, search for the submited ticket + # class using its prefix. Assuming ticket is a ProxyTicket or a ServiceTicket + if cls == Ticket: + ticket_class = cls.get_class(ticket, classes=[ServiceTicket, ProxyTicket]) + # else use the method class + else: + ticket_class = cls + # If ticket prefix is wrong, raise DoesNotExist + if cls != Ticket and not ticket.startswith(cls.PREFIX): + raise Ticket.DoesNotExist() + if ticket_class: + # search for the ticket that is not yet validated and is still valid + ticket_queryset = ticket_class.objects.filter( + value=ticket, + validate=False, + creation__gt=(timezone.now() - timedelta(seconds=ticket_class.VALIDITY)) + ) + # if service is specified, add it the the queryset + if service is not None: + ticket_queryset = ticket_queryset.filter(service=service) + # only require renew if renew is True, otherwise it do not matter if renew is True + # or False. + if renew: + ticket_queryset = ticket_queryset.filter(renew=True) + # fetch the ticket ``MultipleObjectsReturned`` is never raised as the ticket value + # is unique across the database + ticket = ticket_queryset.get() + # For ServiceTicket and Proxyticket, mark it as validated before returning + if ticket_class != ProxyGrantingTicket: + ticket.validate = True + ticket.save() + return ticket + # If no class found for the ticket, raise DoesNotExist + else: + raise Ticket.DoesNotExist() + @python_2_unicode_compatible class ServiceTicket(Ticket): - """A Service Ticket""" + """ + Bases: :class:`Ticket` + + A Service Ticket + """ + #: The ticket prefix used to differentiate it from other tickets types PREFIX = settings.CAS_SERVICE_TICKET_PREFIX + #: The ticket value value = models.CharField(max_length=255, default=utils.gen_st, unique=True) def __str__(self): @@ -614,8 +952,14 @@ class ServiceTicket(Ticket): @python_2_unicode_compatible class ProxyTicket(Ticket): - """A Proxy Ticket""" + """ + Bases: :class:`Ticket` + + A Proxy Ticket + """ + #: The ticket prefix used to differentiate it from other tickets types PREFIX = settings.CAS_PROXY_TICKET_PREFIX + #: The ticket value value = models.CharField(max_length=255, default=utils.gen_pt, unique=True) def __str__(self): @@ -624,9 +968,17 @@ class ProxyTicket(Ticket): @python_2_unicode_compatible class ProxyGrantingTicket(Ticket): - """A Proxy Granting Ticket""" + """ + Bases: :class:`Ticket` + + A Proxy Granting Ticket + """ + #: The ticket prefix used to differentiate it from other tickets types PREFIX = settings.CAS_PROXY_GRANTING_TICKET_PREFIX + #: ProxyGranting ticket are never validated. However, they can be used during :attr:`VALIDITY` + #: to get :class:`ProxyTicket` for :attr:`user` VALIDITY = settings.CAS_PGT_VALIDITY + #: The ticket value value = models.CharField(max_length=255, default=utils.gen_pgt, unique=True) def __str__(self): @@ -635,10 +987,18 @@ class ProxyGrantingTicket(Ticket): @python_2_unicode_compatible class Proxy(models.Model): - """A list of proxies on `ProxyTicket`""" + """ + Bases: :class:`django.db.models.Model` + + A list of proxies on :class:`ProxyTicket` + """ class Meta: ordering = ("-pk", ) + #: Service url of the PGT used for getting the associated :class:`ProxyTicket` url = models.CharField(max_length=255) + #: ForeignKey to a :class:`ProxyTicket`. :class:`Proxy` instances for a + #: :class:`ProxyTicket` are accessible thought its :attr:`ProxyTicket.proxies` + #: attribute. proxy_ticket = models.ForeignKey(ProxyTicket, related_name="proxies") def __str__(self): diff --git a/cas_server/templates/cas_server/base.html b/cas_server/templates/cas_server/base.html index bebf439..db61e1b 100644 --- a/cas_server/templates/cas_server/base.html +++ b/cas_server/templates/cas_server/base.html @@ -1,36 +1,63 @@ -{% extends 'bootstrap3/bootstrap3.html' %} {% load i18n %} -{% block bootstrap3_title %}{% block title %}{% trans "Central Authentication Service" %}{% endblock %}{% endblock %} - {% load staticfiles %} -{% load bootstrap3 %} - -{% block bootstrap3_extra_head %} - - -{% endblock %} - -{% block bootstrap3_content %} -
-{% if auto_submit %}{% endif %} -
-
-
-{% if auto_submit %}{% endif %} -{% block content %} -{% endblock %} -
-
-
-
-{% endblock %} + + + + + + + {% block title %}{% trans "Central Authentication Service" %}{% endblock %} + + + + + + + + +
+ {% if auto_submit %}{% endif %} +
+
+
+ {% block ante_messages %}{% endblock %} + {% if auto_submit %}{% endif %} + {% block content %}{% endblock %} +
+
+
+
+ + + + diff --git a/cas_server/templates/cas_server/form.html b/cas_server/templates/cas_server/form.html new file mode 100644 index 0000000..5ac1463 --- /dev/null +++ b/cas_server/templates/cas_server/form.html @@ -0,0 +1,25 @@ +{% for error in form.non_field_errors %} +
+ + {{error}} +
+{% endfor %} +{% for field in form %}{% if field.display %} +
{% spaceless %} + {% if field.checkbox %} +
+ {% else %} + + {{field}} + {% endif %} + {% for error in field.errors %} + {{error}} + {% endfor %} +{% endspaceless %}
+{% else %}{{field}}{% endif %}{% endfor %} diff --git a/cas_server/templates/cas_server/logged.html b/cas_server/templates/cas_server/logged.html index 9c8bb38..f29445b 100644 --- a/cas_server/templates/cas_server/logged.html +++ b/cas_server/templates/cas_server/logged.html @@ -1,6 +1,4 @@ {% extends "cas_server/base.html" %} -{% load bootstrap3 %} -{% load staticfiles %} {% load i18n %} {% block content %} @@ -10,7 +8,7 @@ {% trans "Log me out from all my sessions" %}
- {% bootstrap_button _('Logout') size='lg' button_type="submit" button_class="btn-danger btn-block"%} + {% endblock %} diff --git a/cas_server/templates/cas_server/login.html b/cas_server/templates/cas_server/login.html index d4559fe..d6adc64 100644 --- a/cas_server/templates/cas_server/login.html +++ b/cas_server/templates/cas_server/login.html @@ -1,18 +1,19 @@ {% extends "cas_server/base.html" %} -{% load bootstrap3 %} -{% load staticfiles %} {% load i18n %} + +{% block ante_messages %} +{% if auto_submit %}{% endif %} +{% endblock %} {% block content %} - + {% if auto_submit %}