Merge pull request #8 from nitmir/dev

Merge dev into master
This commit is contained in:
Valentin Samir 2016-07-27 15:05:24 +02:00 committed by GitHub
commit f837853043
52 changed files with 3345 additions and 618 deletions

4
.gitignore vendored
View file

@ -4,12 +4,13 @@
*.swp *.swp
build/ build/
bootstrap3
cas/ cas/
dist/ dist/
db.sqlite3 db.sqlite3
manage.py manage.py
coverage.xml coverage.xml
docs/_build/
docs/django.inv
.tox .tox
test_venv test_venv
@ -17,3 +18,4 @@ test_venv
htmlcov/ htmlcov/
tox_logs/ tox_logs/
.cache/ .cache/
.eggs/

View file

@ -1,17 +1,28 @@
language: python language: python
python: matrix:
- "2.7" include:
env: - python: "2.7"
matrix: env: TOX_ENV=coverage
- TOX_ENV=coverage - python: "2.7"
- TOX_ENV=flake8 env: TOX_ENV=flake8
- TOX_ENV=check_rst - python: "2.7"
- TOX_ENV=py27-django17 env: TOX_ENV=check_rst
- TOX_ENV=py27-django18 - python: "2.7"
- TOX_ENV=py27-django19 env: TOX_ENV=py27-django17
- TOX_ENV=py34-django17 - python: "2.7"
- TOX_ENV=py34-django18 env: TOX_ENV=py27-django18
- TOX_ENV=py34-django19 - 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: cache:
directories: directories:
- $HOME/.cache/pip/http/ - $HOME/.cache/pip/http/

View file

@ -1,7 +1,21 @@
include tox.ini include tox.ini
include LICENSE include LICENSE
include README.rst include README.rst
include .coveragerc
include Makefile
include pytest.ini
include requirements-dev.txt
include requirements.txt
prune .tox prune .tox
recursive-include cas_server/static * recursive-include cas_server/static *
recursive-include cas_server/templates * recursive-include cas_server/templates *
recursive-include cas_server/locale * 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 *

View file

@ -1,4 +1,4 @@
.PHONY: build dist .PHONY: build dist docs
VERSION=`python setup.py -V` VERSION=`python setup.py -V`
build: build:
@ -24,10 +24,14 @@ clean_coverage:
rm -rf coverage.xml .coverage htmlcov rm -rf coverage.xml .coverage htmlcov
clean_tild_backup: clean_tild_backup:
find ./ -name '*~' -delete 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: 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: dist:
python setup.py sdist python setup.py sdist
@ -40,7 +44,7 @@ test_venv/cas/manage.py: test_venv
mkdir -p test_venv/cas mkdir -p test_venv/cas
test_venv/bin/django-admin startproject cas test_venv/cas test_venv/bin/django-admin startproject cas test_venv/cas
ln -s ../../cas_server test_venv/cas/cas_server 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/'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/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 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 python setup.py check --restructuredtext --stric
test_venv/bin/py.test --cov=cas_server --cov-report html 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 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

View file

@ -1,20 +1,7 @@
CAS Server CAS Server
########## ##########
.. image:: https://travis-ci.org/nitmir/django-cas-server.svg?branch=master |travis| |version| |lisence| |codacy| |coverage|
: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
CAS Server is a Django application implementing the `CAS Protocol 3.0 Specification CAS Server is a Django application implementing the `CAS Protocol 3.0 Specification
<https://apereo.github.io/cas/4.2.x/protocol/CAS-Protocol-Specification.html>`_. <https://apereo.github.io/cas/4.2.x/protocol/CAS-Protocol-Specification.html>`_.
@ -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 By default, the authentication process use django internal users but you can easily
use any sources (see auth classes in the auth.py file) use any sources (see auth classes in the auth.py file)
The default login/logout template use `django-bootstrap3 <https://github.com/dyve/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 <https://github.com/dyve/django-bootstrap3>`__ < 7.0.0
like the 6.2.2 version.
.. contents:: Table of Contents .. contents:: Table of Contents
Features Features
@ -52,8 +32,6 @@ Dependencies
* Django >= 1.7 < 1.10 * Django >= 1.7 < 1.10
* requests >= 2.4 * requests >= 2.4
* requests_futures >= 0.9.5 * 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 * lxml >= 3.4
* six >= 1 * six >= 1
@ -68,7 +46,7 @@ The recommended installation mode is to use a virtualenv with ``--system-site-pa
On debian like systems:: 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 On debian jessie, you can use the version of python-django available in the
`backports <https://backports.debian.org/Instructions/>`_. `backports <https://backports.debian.org/Instructions/>`_.
@ -118,7 +96,6 @@ Quick start
INSTALLED_APPS = ( INSTALLED_APPS = (
'django.contrib.admin', 'django.contrib.admin',
... ...
'bootstrap3',
'cas_server', 'cas_server',
) )
@ -186,6 +163,17 @@ Template settings
* ``CAS_LOGO_URL``: URL to the logo showed in the up left corner on the default * ``CAS_LOGO_URL``: URL to the logo showed in the up left corner on the default
templates. Set it to ``False`` to disable it. 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 * ``CAS_LOGIN_TEMPLATE``: Path to the template showed on ``/login`` then the user
is not autenticated. The default is ``"cas_server/login.html"``. is not autenticated. The default is ``"cas_server/login.html"``.
@ -489,3 +477,20 @@ You could for example do as bellow :
.. code-block:: .. code-block::
10 0 * * * cas-user /path/to/project/manage.py cas_clean_federate 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

View file

@ -9,4 +9,5 @@
# #
# (c) 2015-2016 Valentin Samir # (c) 2015-2016 Valentin Samir
"""A django CAS server application""" """A django CAS server application"""
#: path the the application configuration class
default_app_config = 'cas_server.apps.CasAppConfig' default_app_config = 'cas_server.apps.CasAppConfig'

View file

@ -15,86 +15,155 @@ from .models import Username, ReplaceAttributName, ReplaceAttributValue, FilterA
from .models import FederatedIendityProvider from .models import FederatedIendityProvider
from .forms import TicketForm from .forms import TicketForm
TICKETS_READONLY_FIELDS = ('validate', 'service', 'service_pattern',
'creation', 'renew', 'single_log_out', 'value') class BaseInlines(admin.TabularInline):
TICKETS_FIELDS = ('validate', 'service', 'service_pattern', """
'creation', 'renew', 'single_log_out') 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): class UserAdminInlines(BaseInlines):
"""`ServiceTicket` in admin interface""" """
Bases: :class:`BaseInlines`
Base class for inlines in :class:`UserAdmin` interface
"""
#: The form :class:`TicketForm<cas_server.forms.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<cas_server.models.ServiceTicket>` in admin interface
"""
#: The model which the inline is using.
model = ServiceTicket model = ServiceTicket
extra = 0
form = TicketForm
readonly_fields = TICKETS_READONLY_FIELDS
fields = TICKETS_FIELDS
class ProxyTicketInline(admin.TabularInline): class ProxyTicketInline(UserAdminInlines):
"""`ProxyTicket` in admin interface""" """
Bases: :class:`UserAdminInlines`
:class:`ProxyTicket<cas_server.models.ProxyTicket>` in admin interface
"""
#: The model which the inline is using.
model = ProxyTicket model = ProxyTicket
extra = 0
form = TicketForm
readonly_fields = TICKETS_READONLY_FIELDS
fields = TICKETS_FIELDS
class ProxyGrantingInline(admin.TabularInline): class ProxyGrantingInline(UserAdminInlines):
"""`ProxyGrantingTicket` in admin interface""" """
Bases: :class:`UserAdminInlines`
:class:`ProxyGrantingTicket<cas_server.models.ProxyGrantingTicket>` in admin interface
"""
#: The model which the inline is using.
model = ProxyGrantingTicket model = ProxyGrantingTicket
extra = 0
form = TicketForm
readonly_fields = TICKETS_READONLY_FIELDS
fields = TICKETS_FIELDS[1:]
class UserAdmin(admin.ModelAdmin): class UserAdmin(admin.ModelAdmin):
"""`User` in admin interface""" """
Bases: :class:`django.contrib.admin.ModelAdmin`
:class:`User<cas_server.models.User>` in admin interface
"""
#: See :class:`ServiceTicketInline`, :class:`ProxyTicketInline`, :class:`ProxyGrantingInline`
#: objects below the :class:`UserAdmin` fields.
inlines = (ServiceTicketInline, ProxyTicketInline, ProxyGrantingInline) inlines = (ServiceTicketInline, ProxyTicketInline, ProxyGrantingInline)
#: Fields to display on a object that are read only (not editable).
readonly_fields = ('username', 'date', "session_key") readonly_fields = ('username', 'date', "session_key")
#: Fields to display on a object.
fields = ('username', 'date', "session_key") fields = ('username', 'date', "session_key")
#: Fields to display on the list of class:`UserAdmin` objects.
list_display = ('username', 'date', "session_key") list_display = ('username', 'date', "session_key")
class UsernamesInline(admin.TabularInline): class UsernamesInline(BaseInlines):
"""`Username` in admin interface""" """
Bases: :class:`BaseInlines`
:class:`Username<cas_server.models.Username>` in admin interface
"""
#: The model which the inline is using.
model = Username model = Username
extra = 0
class ReplaceAttributNameInline(admin.TabularInline): class ReplaceAttributNameInline(BaseInlines):
"""`ReplaceAttributName` in admin interface""" """
Bases: :class:`BaseInlines`
:class:`ReplaceAttributName<cas_server.models.ReplaceAttributName>` in admin interface
"""
#: The model which the inline is using.
model = ReplaceAttributName model = ReplaceAttributName
extra = 0
class ReplaceAttributValueInline(admin.TabularInline): class ReplaceAttributValueInline(BaseInlines):
"""`ReplaceAttributValue` in admin interface""" """
Bases: :class:`BaseInlines`
:class:`ReplaceAttributValue<cas_server.models.ReplaceAttributValue>` in admin interface
"""
#: The model which the inline is using.
model = ReplaceAttributValue model = ReplaceAttributValue
extra = 0
class FilterAttributValueInline(admin.TabularInline): class FilterAttributValueInline(BaseInlines):
"""`FilterAttributValue` in admin interface""" """
Bases: :class:`BaseInlines`
:class:`FilterAttributValue<cas_server.models.FilterAttributValue>` in admin interface
"""
#: The model which the inline is using.
model = FilterAttributValue model = FilterAttributValue
extra = 0
class ServicePatternAdmin(admin.ModelAdmin): class ServicePatternAdmin(admin.ModelAdmin):
"""`ServicePattern` in admin interface""" """
Bases: :class:`django.contrib.admin.ModelAdmin`
:class:`ServicePattern<cas_server.models.ServicePattern>` in admin interface
"""
#: See :class:`UsernamesInline`, :class:`ReplaceAttributNameInline`,
#: :class:`ReplaceAttributValueInline`, :class:`FilterAttributValueInline` objects below
#: the :class:`ServicePatternAdmin` fields.
inlines = ( inlines = (
UsernamesInline, UsernamesInline,
ReplaceAttributNameInline, ReplaceAttributNameInline,
ReplaceAttributValueInline, ReplaceAttributValueInline,
FilterAttributValueInline FilterAttributValueInline
) )
#: Fields to display on the list of class:`ServicePatternAdmin` objects.
list_display = ('pos', 'name', 'pattern', 'proxy', list_display = ('pos', 'name', 'pattern', 'proxy',
'single_log_out', 'proxy_callback', 'restrict_users') 'single_log_out', 'proxy_callback', 'restrict_users')
class FederatedIendityProviderAdmin(admin.ModelAdmin): class FederatedIendityProviderAdmin(admin.ModelAdmin):
"""`FederatedIendityProvider` in admin interface""" """
Bases: :class:`django.contrib.admin.ModelAdmin`
:class:`FederatedIendityProvider<cas_server.models.FederatedIendityProvider>` in admin
interface
"""
#: Fields to display on a object.
fields = ('pos', 'suffix', 'server_url', 'cas_protocol_version', 'verbose_name', 'display') 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') list_display = ('verbose_name', 'suffix', 'display')

View file

@ -14,6 +14,12 @@ from django.apps import AppConfig
class CasAppConfig(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' name = 'cas_server'
#: Human-readable name for the application.
verbose_name = _('Central Authentication Service') verbose_name = _('Central Authentication Service')

View file

@ -26,55 +26,112 @@ from .models import FederatedUser
class AuthUser(object): 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): def __init__(self, username):
self.username = username self.username = username
def test_password(self, password): 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() raise NotImplementedError()
def attributs(self): 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() raise NotImplementedError()
class DummyAuthUser(AuthUser): # pragma: no cover class DummyAuthUser(AuthUser): # pragma: no cover
"""A Dummy authentication class""" """
A Dummy authentication class. Authentication always fails
def __init__(self, username): :param unicode username: A username, stored in the :attr:`username<AuthUser.username>`
super(DummyAuthUser, self).__init__(username) class attribute. There is no valid value for this attribute here.
"""
def test_password(self, password): 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 return False
def attributs(self): def attributs(self):
"""return a dict of user attributes""" """
The user attributes.
:return: en empty :class:`dict`.
:rtype: dict
"""
return {} return {}
class TestAuthUser(AuthUser): 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): :param unicode username: A username, stored in the :attr:`username<AuthUser.username>`
super(TestAuthUser, self).__init__(username) class attribute. The uniq valid value is ``settings.CAS_TEST_USER``.
"""
def test_password(self, password): 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<AuthUser.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 return self.username == settings.CAS_TEST_USER and password == settings.CAS_TEST_PASSWORD
def attributs(self): 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<AuthUser.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 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<AuthUser.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 user = None
def __init__(self, username): 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 = { mysql_config = {
"user": settings.CAS_SQL_USERNAME, "user": settings.CAS_SQL_USERNAME,
"passwd": settings.CAS_SQL_PASSWORD, "passwd": settings.CAS_SQL_PASSWORD,
@ -94,7 +151,14 @@ class MysqlAuthUser(AuthUser): # pragma: no cover
super(MysqlAuthUser, self).__init__(username) super(MysqlAuthUser, self).__init__(username)
def test_password(self, password): 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<AuthUser.username>` is valid and ``password`` is
correct, ``False`` otherwise.
:rtype: bool
"""
if self.user: if self.user:
return check_password( return check_password(
settings.CAS_SQL_PASSWORD_CHECK, settings.CAS_SQL_PASSWORD_CHECK,
@ -106,7 +170,14 @@ class MysqlAuthUser(AuthUser): # pragma: no cover
return False return False
def attributs(self): 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: if self.user:
return self.user return self.user
else: else:
@ -114,7 +185,14 @@ class MysqlAuthUser(AuthUser): # pragma: no cover
class DjangoAuthUser(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<AuthUser.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 user = None
def __init__(self, username): def __init__(self, username):
@ -126,14 +204,27 @@ class DjangoAuthUser(AuthUser): # pragma: no cover
super(DjangoAuthUser, self).__init__(username) super(DjangoAuthUser, self).__init__(username)
def test_password(self, password): 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: if self.user:
return self.user.check_password(password) return self.user.check_password(password)
else: else:
return False return False
def attributs(self): 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: if self.user:
attr = {} attr = {}
for field in self.user._meta.fields: for field in self.user._meta.fields:
@ -144,7 +235,16 @@ class DjangoAuthUser(AuthUser): # pragma: no cover
class CASFederateAuth(AuthUser): 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<AuthUser.username>`
class attribute. Valid value are usernames of
:class:`FederatedUser<cas_server.models.FederatedUser>` object.
:class:`FederatedUser<cas_server.models.FederatedUser>` object are created on CAS
backends successful ticket validation.
"""
#: a :class`FederatedUser<cas_server.models.FederatedUser>` object if ``username`` is found.
user = None user = None
def __init__(self, username): def __init__(self, username):
@ -157,7 +257,17 @@ class CASFederateAuth(AuthUser):
super(CASFederateAuth, self).__init__(username) super(CASFederateAuth, self).__init__(username)
def test_password(self, ticket): 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<cas_server.models.FederatedUser>`. ``False`` otherwise.
:rtype: bool
"""
if not self.user or not self.user.ticket: if not self.user or not self.user.ticket:
return False return False
else: else:
@ -168,7 +278,13 @@ class CASFederateAuth(AuthUser):
) )
def attributs(self): def attributs(self):
"""return a dict of user attributes""" """
The user attributes, as returned by the CAS backend.
:return: :obj:`FederatedUser.attributs<cas_server.models.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) if not self.user: # pragma: no cover (should not happen)
return {} return {}
else: else:

View file

@ -36,7 +36,7 @@ class CASError(ValueError):
class ReturnUnicode(object): class ReturnUnicode(object):
@staticmethod @staticmethod
def unicode(string, charset): def u(string, charset):
if not isinstance(string, six.text_type): if not isinstance(string, six.text_type):
return string.decode(charset) return string.decode(charset)
else: else:
@ -157,7 +157,7 @@ class CASClientV1(CASClientBase, ReturnUnicode):
charset = content_type.split("charset=")[-1] charset = content_type.split("charset=")[-1]
else: else:
charset = "ascii" charset = "ascii"
user = self.unicode(page.readline().strip(), charset) user = self.u(page.readline().strip(), charset)
return user, None, None return user, None, None
else: else:
return None, None, None return None, None, None
@ -202,18 +202,18 @@ class CASClientV2(CASClientBase, ReturnUnicode):
def parse_attributes_xml_element(cls, element, charset): def parse_attributes_xml_element(cls, element, charset):
attributes = dict() attributes = dict()
for attribute in element: 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 tag in attributes:
if isinstance(attributes[tag], list): if isinstance(attributes[tag], list):
attributes[tag].append(cls.unicode(attribute.text, charset)) attributes[tag].append(cls.u(attribute.text, charset))
else: else:
attributes[tag] = [attributes[tag]] attributes[tag] = [attributes[tag]]
attributes[tag].append(cls.unicode(attribute.text, charset)) attributes[tag].append(cls.u(attribute.text, charset))
else: else:
if tag == u'attraStyle': if tag == u'attraStyle':
pass pass
else: else:
attributes[tag] = cls.unicode(attribute.text, charset) attributes[tag] = cls.u(attribute.text, charset)
return attributes return attributes
@classmethod @classmethod
@ -238,9 +238,9 @@ class CASClientV2(CASClientBase, ReturnUnicode):
if tree[0].tag.endswith('authenticationSuccess'): if tree[0].tag.endswith('authenticationSuccess'):
for element in tree[0]: for element in tree[0]:
if element.tag.endswith('user'): if element.tag.endswith('user'):
user = cls.unicode(element.text, charset) user = cls.u(element.text, charset)
elif element.tag.endswith('proxyGrantingTicket'): elif element.tag.endswith('proxyGrantingTicket'):
pgtiou = cls.unicode(element.text, charset) pgtiou = cls.u(element.text, charset)
elif element.tag.endswith('attributes'): elif element.tag.endswith('attributes'):
attributes = cls.parse_attributes_xml_element(element, charset) attributes = cls.parse_attributes_xml_element(element, charset)
return user, attributes, pgtiou return user, attributes, pgtiou
@ -255,15 +255,15 @@ class CASClientV3(CASClientV2, SingleLogoutMixin):
def parse_attributes_xml_element(cls, element, charset): def parse_attributes_xml_element(cls, element, charset):
attributes = dict() attributes = dict()
for attribute in element: 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 tag in attributes:
if isinstance(attributes[tag], list): if isinstance(attributes[tag], list):
attributes[tag].append(cls.unicode(attribute.text, charset)) attributes[tag].append(cls.u(attribute.text, charset))
else: else:
attributes[tag] = [attributes[tag]] attributes[tag] = [attributes[tag]]
attributes[tag].append(cls.unicode(attribute.text, charset)) attributes[tag].append(cls.u(attribute.text, charset))
else: else:
attributes[tag] = cls.unicode(attribute.text, charset) attributes[tag] = cls.u(attribute.text, charset)
return attributes return attributes
@classmethod @classmethod
@ -323,25 +323,25 @@ class CASClientWithSAMLV1(CASClientV2, SingleLogoutMixin):
# User is validated # User is validated
name_identifier = tree.find('.//' + SAML_1_0_ASSERTION_NS + 'NameIdentifier') name_identifier = tree.find('.//' + SAML_1_0_ASSERTION_NS + 'NameIdentifier')
if name_identifier is not None: 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') attrs = tree.findall('.//' + SAML_1_0_ASSERTION_NS + 'Attribute')
for at in attrs: for at in attrs:
if self.username_attribute in list(at.attrib.values()): if self.username_attribute in list(at.attrib.values()):
user = self.unicode( user = self.u(
at.find(SAML_1_0_ASSERTION_NS + 'AttributeValue').text, at.find(SAML_1_0_ASSERTION_NS + 'AttributeValue').text,
charset charset
) )
attributes[u'uid'] = user attributes[u'uid'] = user
values = at.findall(SAML_1_0_ASSERTION_NS + 'AttributeValue') 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: if len(values) > 1:
values_array = [] values_array = []
for v in values: for v in values:
values_array.append(self.unicode(v.text, charset)) values_array.append(self.u(v.text, charset))
attributes[key] = values_array attributes[key] = values_array
else: else:
attributes[key] = self.unicode(values[0].text, charset) attributes[key] = self.u(values[0].text, charset)
return user, attributes, None return user, attributes, None
finally: finally:
page.close() page.close()

View file

@ -13,84 +13,146 @@
from django.conf import settings from django.conf import settings
from django.contrib.staticfiles.templatetags.staticfiles import static 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) value = getattr(settings, name, default_value)
# set the setting value to its value if defined, ellse to the default_value.
setattr(settings, name, 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: if settings.CAS_FEDERATE:
settings.CAS_AUTH_CLASS = "cas_server.auth.CASFederateAuth" settings.CAS_AUTH_CLASS = "cas_server.auth.CASFederateAuth"
#: SessionStore class depending of :django:setting:`SESSION_ENGINE`
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore

View file

@ -10,26 +10,37 @@
# #
# (c) 2016 Valentin Samir # (c) 2016 Valentin Samir
"""federated mode helper classes""" """federated mode helper classes"""
from .default_settings import settings from .default_settings import SessionStore
from django.db import IntegrityError from django.db import IntegrityError
from .cas import CASClient from .cas import CASClient
from .models import FederatedUser, FederateSLO, User from .models import FederatedUser, FederateSLO, User
import logging import logging
from importlib import import_module
from six.moves import urllib from six.moves import urllib
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore #: logger facility
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class CASFederateValidateUser(object): 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 username = None
#: the provider returned attributes
attributs = {} attributs = {}
#: the CAS client instance
client = None 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): def __init__(self, provider, service_url):
self.provider = provider self.provider = provider
@ -41,15 +52,31 @@ class CASFederateValidateUser(object):
) )
def get_login_url(self): 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() return self.client.get_login_url()
def get_logout_url(self, redirect_url=None): 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<types.NoneType>`
:return: the CAS provider logout url
:rtype: unicode
"""
return self.client.get_logout_url(redirect_url) return self.client.get_logout_url(redirect_url)
def verify_ticket(self, ticket): 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<cas_server.models.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: try:
username, attributs = self.client.verify_ticket(ticket)[:2] username, attributs = self.client.verify_ticket(ticket)[:2]
except urllib.error.URLError: except urllib.error.URLError:
@ -57,7 +84,7 @@ class CASFederateValidateUser(object):
if username is not None: if username is not None:
if attributs is None: if attributs is None:
attributs = {} attributs = {}
attributs["provider"] = self.provider attributs["provider"] = self.provider.suffix
self.username = username self.username = username
self.attributs = attributs self.attributs = attributs
user = FederatedUser.objects.update_or_create( user = FederatedUser.objects.update_or_create(
@ -73,7 +100,15 @@ class CASFederateValidateUser(object):
@staticmethod @staticmethod
def register_slo(username, session_key, ticket): 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: try:
FederateSLO.objects.create( FederateSLO.objects.create(
username=username, username=username,
@ -84,7 +119,14 @@ class CASFederateValidateUser(object):
pass pass
def clean_sessions(self, logout_request): 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: try:
slos = self.client.get_saml_slos(logout_request) or [] slos = self.client.get_saml_slos(logout_request) or []
except NameError: # pragma: no cover (should not happen) except NameError: # pragma: no cover (should not happen)

View file

@ -18,21 +18,55 @@ import cas_server.utils as utils
import cas_server.models as models import cas_server.models as models
class WarnForm(forms.Form): class BootsrapForm(forms.Form):
"""Form used on warn page before emiting a ticket""" """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) service = forms.CharField(widget=forms.HiddenInput(), required=False)
#: Is the service asking the authentication renewal ?
renew = forms.BooleanField(widget=forms.HiddenInput(), required=False) 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) gateway = forms.CharField(widget=forms.HiddenInput(), required=False)
method = 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) warned = forms.BooleanField(widget=forms.HiddenInput(), required=False)
#: A valid LoginTicket to prevent POST replay
lt = forms.CharField(widget=forms.HiddenInput(), required=False) 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 Bases: :class:`django.forms.Form`
allowing the user to choose a identity provider.
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( provider = forms.ModelChoiceField(
queryset=models.FederatedIendityProvider.objects.filter(display=True).order_by( queryset=models.FederatedIendityProvider.objects.filter(display=True).order_by(
"pos", "pos",
@ -42,27 +76,49 @@ class FederateSelect(forms.Form):
to_field_name="suffix", to_field_name="suffix",
label=_('Identity provider'), label=_('Identity provider'),
) )
#: The service url for which the user want a ticket
service = forms.CharField(label=_('service'), widget=forms.HiddenInput(), required=False) service = forms.CharField(label=_('service'), widget=forms.HiddenInput(), required=False)
method = forms.CharField(widget=forms.HiddenInput(), required=False) method = forms.CharField(widget=forms.HiddenInput(), required=False)
#: A checkbox to remember the user choices of :attr:`provider<FederateSelect.provider>`
remember = forms.BooleanField(label=_('Remember the identity provider'), required=False) 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) warn = forms.BooleanField(label=_('warn'), required=False)
#: Is the service asking the authentication renewal ?
renew = forms.BooleanField(widget=forms.HiddenInput(), required=False) renew = forms.BooleanField(widget=forms.HiddenInput(), required=False)
class UserCredential(forms.Form): class UserCredential(BootsrapForm):
"""Form used on the login page to retrive user credentials""" """
Bases: :class:`django.forms.Form`
Form used on the login page to retrive user credentials
"""
#: The user username
username = forms.CharField(label=_('login')) 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) service = forms.CharField(label=_('service'), widget=forms.HiddenInput(), required=False)
#: The user password
password = forms.CharField(label=_('password'), widget=forms.PasswordInput) password = forms.CharField(label=_('password'), widget=forms.PasswordInput)
#: A valid LoginTicket to prevent POST replay
lt = forms.CharField(widget=forms.HiddenInput(), required=False) lt = forms.CharField(widget=forms.HiddenInput(), required=False)
method = 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) warn = forms.BooleanField(label=_('warn'), required=False)
#: Is the service asking the authentication renewal ?
renew = forms.BooleanField(widget=forms.HiddenInput(), required=False) renew = forms.BooleanField(widget=forms.HiddenInput(), required=False)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(UserCredential, self).__init__(*args, **kwargs) super(UserCredential, self).__init__(*args, **kwargs)
def clean(self): 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() cleaned_data = super(UserCredential, self).clean()
auth = utils.import_attr(settings.CAS_AUTH_CLASS)(cleaned_data.get("username")) auth = utils.import_attr(settings.CAS_AUTH_CLASS)(cleaned_data.get("username"))
if auth.test_password(cleaned_data.get("password")): if auth.test_password(cleaned_data.get("password")):
@ -73,17 +129,51 @@ class UserCredential(forms.Form):
class FederateUserCredential(UserCredential): 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<cas_server.views.FederateAuth>` and
:class:`LoginView<cas_server.views.LoginView>`.
On successful authentication on a provider, in the view
:class:`FederateAuth<cas_server.views.FederateAuth>` a
:class:`FederatedUser<cas_server.models.FederatedUser>` is created by
:meth:`cas_server.federate.CASFederateValidateUser.verify_ticket` and the user is redirected
to :class:`LoginView<cas_server.views.LoginView>`. This form is then automatically filled
with infos matching the created :class:`FederatedUser<cas_server.models.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<cas_server.views.LoginView>` view.
"""
#: the user username with the ``@`` component
username = forms.CharField(widget=forms.HiddenInput()) username = forms.CharField(widget=forms.HiddenInput())
#: The service url for which the user want a ticket
service = forms.CharField(widget=forms.HiddenInput(), required=False) service = forms.CharField(widget=forms.HiddenInput(), required=False)
#: The ``ticket`` used to authenticate the user against a provider
password = forms.CharField(widget=forms.HiddenInput()) password = forms.CharField(widget=forms.HiddenInput())
#: alias of :attr:`password`
ticket = forms.CharField(widget=forms.HiddenInput()) ticket = forms.CharField(widget=forms.HiddenInput())
#: A valid LoginTicket to prevent POST replay
lt = forms.CharField(widget=forms.HiddenInput(), required=False) lt = forms.CharField(widget=forms.HiddenInput(), required=False)
method = 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) warn = forms.BooleanField(widget=forms.HiddenInput(), required=False)
#: Is the service asking the authentication renewal ?
renew = forms.BooleanField(widget=forms.HiddenInput(), required=False) renew = forms.BooleanField(widget=forms.HiddenInput(), required=False)
def clean(self): def clean(self):
"""
Validate that the submited :attr:`username` and :attr:`password` are valid using
the :class:`CASFederateAuth<cas_server.auth.CASFederateAuth>` auth class.
:raises django.forms.ValidationError: if the :attr:`username` and :attr:`password`
do not correspond to a :class:`FederatedUser<cas_server.models.FederatedUser>`.
:return: The cleaned POST data
:rtype: dict
"""
cleaned_data = super(FederateUserCredential, self).clean() cleaned_data = super(FederateUserCredential, self).clean()
try: try:
user = models.FederatedUser.get_from_federated_username(cleaned_data["username"]) user = models.FederatedUser.get_from_federated_username(cleaned_data["username"])
@ -99,7 +189,11 @@ class FederateUserCredential(UserCredential):
class TicketForm(forms.ModelForm): 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: class Meta:
model = models.Ticket model = models.Ticket
exclude = [] exclude = []

View file

@ -4,7 +4,6 @@ from __future__ import unicode_literals
from django.db import models, migrations from django.db import models, migrations
import django.db.models.deletion import django.db.models.deletion
import cas_server.utils import cas_server.utils
import picklefield.fields
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -31,7 +30,7 @@ class Migration(migrations.Migration):
name='ProxyGrantingTicket', name='ProxyGrantingTicket',
fields=[ fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('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)), ('validate', models.BooleanField(default=False)),
('service', models.TextField()), ('service', models.TextField()),
('creation', models.DateTimeField(auto_now_add=True)), ('creation', models.DateTimeField(auto_now_add=True)),
@ -47,7 +46,7 @@ class Migration(migrations.Migration):
name='ProxyTicket', name='ProxyTicket',
fields=[ fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('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)), ('validate', models.BooleanField(default=False)),
('service', models.TextField()), ('service', models.TextField()),
('creation', models.DateTimeField(auto_now_add=True)), ('creation', models.DateTimeField(auto_now_add=True)),
@ -80,7 +79,7 @@ class Migration(migrations.Migration):
name='ServiceTicket', name='ServiceTicket',
fields=[ fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('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)), ('validate', models.BooleanField(default=False)),
('service', models.TextField()), ('service', models.TextField()),
('creation', models.DateTimeField(auto_now_add=True)), ('creation', models.DateTimeField(auto_now_add=True)),

View file

@ -3,7 +3,6 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models
import picklefield.fields
import django.db.models.deletion 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')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('username', models.CharField(max_length=124)), ('username', models.CharField(max_length=124)),
('provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cas_server.FederatedIendityProvider')), ('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)), ('ticket', models.CharField(max_length=255)),
('last_update', models.DateTimeField(auto_now=True)), ('last_update', models.DateTimeField(auto_now=True)),
], ],

View file

@ -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'),
),
]

View file

@ -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'),
),
]

View file

@ -10,7 +10,7 @@
# #
# (c) 2015-2016 Valentin Samir # (c) 2015-2016 Valentin Samir
"""models for the app""" """models for the app"""
from .default_settings import settings from .default_settings import settings, SessionStore
from django.db import models from django.db import models
from django.db.models import Q 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.translation import ugettext_lazy as _
from django.utils import timezone from django.utils import timezone
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
from picklefield.fields import PickledObjectField
import re import re
import sys import sys
import logging import logging
from importlib import import_module
from datetime import timedelta from datetime import timedelta
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from requests_futures.sessions import FuturesSession from requests_futures.sessions import FuturesSession
import cas_server.utils as utils import cas_server.utils as utils
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore #: logger facility
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@python_2_unicode_compatible @python_2_unicode_compatible
class FederatedIendityProvider(models.Model): 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: class Meta:
verbose_name = _("identity provider") verbose_name = _(u"identity provider")
verbose_name_plural = _("identity providers") verbose_name_plural = _(u"identity providers")
#: Suffix append to backend CAS returned username: ``returned_username`` @ ``suffix``.
#: it must be unique.
suffix = models.CharField( suffix = models.CharField(
max_length=30, max_length=30,
unique=True, unique=True,
verbose_name=_(u"suffix"), 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")) 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( cas_protocol_version = models.CharField(
max_length=30, max_length=30,
choices=[ choices=[
@ -57,28 +67,37 @@ class FederatedIendityProvider(models.Model):
("CAS_2_SAML_1_0", "SAML 1.1") ("CAS_2_SAML_1_0", "SAML 1.1")
], ],
verbose_name=_(u"CAS protocol version"), 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" default="3"
) )
#: Name for this identity provider displayed on the login page.
verbose_name = models.CharField( verbose_name = models.CharField(
max_length=255, max_length=255,
verbose_name=_(u"verbose name"), 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( pos = models.IntegerField(
default=100, default=100,
verbose_name=_(u"position"), verbose_name=_(u"position"),
help_text=_( help_text=_(
( (
u"Position of the identity provider on the login page. "
u"Identity provider are sorted using the " 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( display = models.BooleanField(
default=True, default=True,
verbose_name=_(u"display"), 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): def __str__(self):
@ -86,36 +105,72 @@ class FederatedIendityProvider(models.Model):
@staticmethod @staticmethod
def build_username_from_suffix(username, suffix): 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) return u'%s@%s' % (username, suffix)
def build_username(self, username): 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) return u'%s@%s' % (username, self.suffix)
@python_2_unicode_compatible @python_2_unicode_compatible
class FederatedUser(models.Model): 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: class Meta:
unique_together = ("username", "provider") unique_together = ("username", "provider")
#: The user username returned by the CAS backend on successful ticket validation
username = models.CharField(max_length=124) username = models.CharField(max_length=124)
#: A foreign key to :class:`FederatedIendityProvider`
provider = models.ForeignKey(FederatedIendityProvider, on_delete=models.CASCADE) 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) 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) last_update = models.DateTimeField(auto_now=True)
def __str__(self): def __str__(self):
return self.federated_username 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 @property
def federated_username(self): 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) return self.provider.build_username(self.username)
@classmethod @classmethod
def get_from_federated_username(cls, username): 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: if username is None:
raise cls.DoesNotExist() raise cls.DoesNotExist()
else: else:
@ -130,7 +185,7 @@ class FederatedUser(models.Model):
@classmethod @classmethod
def clean_old_entries(cls): def clean_old_entries(cls):
"""remove old unused federated users""" """remove old unused :class:`FederatedUser`"""
federated_users = cls.objects.filter( federated_users = cls.objects.filter(
last_update__lt=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_TIMEOUT)) last_update__lt=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_TIMEOUT))
) )
@ -141,16 +196,23 @@ class FederatedUser(models.Model):
class FederateSLO(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: class Meta:
unique_together = ("username", "session_key", "ticket") unique_together = ("username", "session_key", "ticket")
#: the federated username with the ``@``component
username = models.CharField(max_length=30) 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) 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) ticket = models.CharField(max_length=255, db_index=True)
@classmethod @classmethod
def clean_deleted_sessions(cls): 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(): for federate_slo in cls.objects.all():
if not SessionStore(session_key=federate_slo.session_key).get('authenticated'): if not SessionStore(session_key=federate_slo.session_key).get('authenticated'):
federate_slo.delete() federate_slo.delete()
@ -158,17 +220,27 @@ class FederateSLO(models.Model):
@python_2_unicode_compatible @python_2_unicode_compatible
class User(models.Model): class User(models.Model):
"""A user logged into the CAS""" """
Bases: :class:`django.db.models.Model`
A user logged into the CAS
"""
class Meta: class Meta:
unique_together = ("username", "session_key") unique_together = ("username", "session_key")
verbose_name = _("User") verbose_name = _("User")
verbose_name_plural = _("Users") verbose_name_plural = _("Users")
#: The session key of the current authenticated user
session_key = models.CharField(max_length=40, blank=True, null=True) session_key = models.CharField(max_length=40, blank=True, null=True)
#: The username of the current authenticated user
username = models.CharField(max_length=30) username = models.CharField(max_length=30)
#: Last time the authenticated user has do something (auth, fetch ticket, etc…)
date = models.DateTimeField(auto_now=True) date = models.DateTimeField(auto_now=True)
def delete(self, *args, **kwargs): 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: if settings.CAS_FEDERATE:
FederateSLO.objects.filter( FederateSLO.objects.filter(
username=self.username, username=self.username,
@ -178,7 +250,10 @@ class User(models.Model):
@classmethod @classmethod
def clean_old_entries(cls): 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( users = cls.objects.filter(
date__lt=(timezone.now() - timedelta(seconds=settings.SESSION_COOKIE_AGE)) date__lt=(timezone.now() - timedelta(seconds=settings.SESSION_COOKIE_AGE))
) )
@ -188,7 +263,7 @@ class User(models.Model):
@classmethod @classmethod
def clean_deleted_sessions(cls): 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(): for user in cls.objects.all():
if not SessionStore(session_key=user.session_key).get('authenticated'): if not SessionStore(session_key=user.session_key).get('authenticated'):
user.logout() user.logout()
@ -196,14 +271,22 @@ class User(models.Model):
@property @property
def attributs(self): 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() return utils.import_attr(settings.CAS_AUTH_CLASS)(self.username).attributs()
def __str__(self): def __str__(self):
return u"%s - %s" % (self.username, self.session_key) return u"%s - %s" % (self.username, self.session_key)
def logout(self, request=None): 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<types.NoneType>`
"""
async_list = [] async_list = []
session = FuturesSession( session = FuturesSession(
executor=ThreadPoolExecutor(max_workers=settings.CAS_SLO_MAX_PARALLEL_REQUESTS) 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): def get_ticket(self, ticket_class, service, service_pattern, renew):
""" """
Generate a ticket using `ticket_class` for the service Generate a ticket using ``ticket_class`` for the service
`service` matching `service_pattern` and asking or not for ``service`` matching ``service_pattern`` and asking or not for
authentication renewal with `renew 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( attributs = dict(
(a.name, a.replace if a.replace else a.name) for a in service_pattern.attributs.all() (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 return ticket
def get_service_url(self, service, service_pattern, renew): 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) ticket = self.get_ticket(ServiceTicket, service, service_pattern, renew)
url = utils.update_url(service, {'ticket': ticket.value}) url = utils.update_url(service, {'ticket': ticket.value})
logger.info("Service ticket created for service %s by user %s." % (service, self.username)) 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): 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 pass
class BadUsername(ServicePatternException): 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 pass
class BadFilter(ServicePatternException): 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 pass
class UserFieldNotDefined(ServicePatternException): 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 pass
@python_2_unicode_compatible @python_2_unicode_compatible
class ServicePattern(models.Model): 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: class Meta:
ordering = ("pos", ) ordering = ("pos", )
verbose_name = _("Service pattern") verbose_name = _("Service pattern")
verbose_name_plural = _("Services patterns") verbose_name_plural = _("Services patterns")
#: service patterns are sorted using the :attr:`pos` attribute
pos = models.IntegerField( pos = models.IntegerField(
default=100, default=100,
verbose_name=_(u"position"), verbose_name=_(u"position"),
help_text=_(u"service patterns are sorted using the position attribute") 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( name = models.CharField(
max_length=255, max_length=255,
unique=True, unique=True,
@ -325,6 +452,9 @@ class ServicePattern(models.Model):
verbose_name=_(u"name"), verbose_name=_(u"name"),
help_text=_(u"A name for the service") 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( pattern = models.CharField(
max_length=255, max_length=255,
unique=True, unique=True,
@ -335,6 +465,7 @@ class ServicePattern(models.Model):
"As it is a regular expression, special character must be escaped with a '\\'." "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( user_field = models.CharField(
max_length=255, max_length=255,
default="", default="",
@ -342,27 +473,35 @@ class ServicePattern(models.Model):
verbose_name=_(u"user field"), verbose_name=_(u"user field"),
help_text=_("Name of the attribut to transmit as username, empty = login") 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( restrict_users = models.BooleanField(
default=False, default=False,
verbose_name=_(u"restrict username"), verbose_name=_(u"restrict username"),
help_text=_("Limit username allowed to connect to the list provided bellow") 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( proxy = models.BooleanField(
default=False, default=False,
verbose_name=_(u"proxy"), verbose_name=_(u"proxy"),
help_text=_("Proxy tickets can be delivered to the service") 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( proxy_callback = models.BooleanField(
default=False, default=False,
verbose_name=_(u"proxy callback"), verbose_name=_(u"proxy callback"),
help_text=_("can be used as a proxy callback to deliver PGT") 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( single_log_out = models.BooleanField(
default=False, default=False,
verbose_name=_(u"single log out"), verbose_name=_(u"single log out"),
help_text=_("Enable SLO for the service") 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( single_log_out_callback = models.CharField(
max_length=255, max_length=255,
default="", default="",
@ -376,7 +515,20 @@ class ServicePattern(models.Model):
return u"%s: %s" % (self.pos, self.pattern) return u"%s: %s" % (self.pos, self.pattern)
def check_user(self, user): 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): 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)) logger.warning("Username %s not allowed on service %s" % (user.username, self.name))
raise BadUsername() raise BadUsername()
@ -416,8 +568,15 @@ class ServicePattern(models.Model):
@classmethod @classmethod
def validate(cls, service): 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'): for service_pattern in cls.objects.all().order_by('pos'):
if re.match(service_pattern.pattern, service): if re.match(service_pattern.pattern, service):
return service_pattern return service_pattern
@ -427,12 +586,20 @@ class ServicePattern(models.Model):
@python_2_unicode_compatible @python_2_unicode_compatible
class Username(models.Model): 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( value = models.CharField(
max_length=255, max_length=255,
verbose_name=_(u"username"), verbose_name=_(u"username"),
help_text=_(u"username allowed to connect to the service") 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") service_pattern = models.ForeignKey(ServicePattern, related_name="usernames")
def __str__(self): def __str__(self):
@ -441,14 +608,23 @@ class Username(models.Model):
@python_2_unicode_compatible @python_2_unicode_compatible
class ReplaceAttributName(models.Model): 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: class Meta:
unique_together = ('name', 'replace', 'service_pattern') unique_together = ('name', 'replace', 'service_pattern')
#: Name the attribute: a key of :attr:`User.attributs`
name = models.CharField( name = models.CharField(
max_length=255, max_length=255,
verbose_name=_(u"name"), 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 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( replace = models.CharField(
max_length=255, max_length=255,
blank=True, blank=True,
@ -456,6 +632,9 @@ class ReplaceAttributName(models.Model):
help_text=_(u"name under which the attribut will be show" help_text=_(u"name under which the attribut will be show"
u"to the service. empty = default name of the attribut") 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") service_pattern = models.ForeignKey(ServicePattern, related_name="attributs")
def __str__(self): def __str__(self):
@ -467,17 +646,29 @@ class ReplaceAttributName(models.Model):
@python_2_unicode_compatible @python_2_unicode_compatible
class FilterAttributValue(models.Model): 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( attribut = models.CharField(
max_length=255, max_length=255,
verbose_name=_(u"attribut"), verbose_name=_(u"attribut"),
help_text=_(u"Name of the attribut which must verify pattern") 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( pattern = models.CharField(
max_length=255, max_length=255,
verbose_name=_(u"pattern"), verbose_name=_(u"pattern"),
help_text=_(u"a regular expression") 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") service_pattern = models.ForeignKey(ServicePattern, related_name="filters")
def __str__(self): def __str__(self):
@ -486,23 +677,34 @@ class FilterAttributValue(models.Model):
@python_2_unicode_compatible @python_2_unicode_compatible
class ReplaceAttributValue(models.Model): 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( attribut = models.CharField(
max_length=255, max_length=255,
verbose_name=_(u"attribut"), verbose_name=_(u"attribut"),
help_text=_(u"Name of the attribut for which the value must be replace") 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( pattern = models.CharField(
max_length=255, max_length=255,
verbose_name=_(u"pattern"), verbose_name=_(u"pattern"),
help_text=_(u"An regular expression maching whats need to be replaced") 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( replace = models.CharField(
max_length=255, max_length=255,
blank=True, blank=True,
verbose_name=_(u"replace"), verbose_name=_(u"replace"),
help_text=_(u"replace expression, groups are capture by \\1, \\2 …") 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") service_pattern = models.ForeignKey(ServicePattern, related_name="replacements")
def __str__(self): def __str__(self):
@ -511,21 +713,54 @@ class ReplaceAttributValue(models.Model):
@python_2_unicode_compatible @python_2_unicode_compatible
class Ticket(models.Model): class Ticket(models.Model):
"""Generic class for a Ticket""" """
Bases: :class:`django.db.models.Model`
Generic class for a Ticket
"""
class Meta: class Meta:
abstract = True abstract = True
#: ForeignKey to a :class:`User`.
user = models.ForeignKey(User, related_name="%(class)s") 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) validate = models.BooleanField(default=False)
#: The service url for the ticket
service = models.TextField() 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") service_pattern = models.ForeignKey(ServicePattern, related_name="%(class)s")
#: Date of the ticket creation
creation = models.DateTimeField(auto_now_add=True) creation = models.DateTimeField(auto_now_add=True)
#: A boolean. ``True`` if the user has just renew his authentication
renew = models.BooleanField(default=False) 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) 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 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 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): def __str__(self):
return u"Ticket-%s" % self.pk return u"Ticket-%s" % self.pk
@ -596,16 +831,119 @@ class Ticket(models.Model):
) )
@staticmethod @staticmethod
def get_class(ticket): def get_class(ticket, classes=None):
for ticket_class in [ServiceTicket, ProxyTicket, ProxyGrantingTicket]: """
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<types.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): if ticket.startswith(ticket_class.PREFIX):
return ticket_class 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 @python_2_unicode_compatible
class ServiceTicket(Ticket): 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 PREFIX = settings.CAS_SERVICE_TICKET_PREFIX
#: The ticket value
value = models.CharField(max_length=255, default=utils.gen_st, unique=True) value = models.CharField(max_length=255, default=utils.gen_st, unique=True)
def __str__(self): def __str__(self):
@ -614,8 +952,14 @@ class ServiceTicket(Ticket):
@python_2_unicode_compatible @python_2_unicode_compatible
class ProxyTicket(Ticket): 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 PREFIX = settings.CAS_PROXY_TICKET_PREFIX
#: The ticket value
value = models.CharField(max_length=255, default=utils.gen_pt, unique=True) value = models.CharField(max_length=255, default=utils.gen_pt, unique=True)
def __str__(self): def __str__(self):
@ -624,9 +968,17 @@ class ProxyTicket(Ticket):
@python_2_unicode_compatible @python_2_unicode_compatible
class ProxyGrantingTicket(Ticket): 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 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 VALIDITY = settings.CAS_PGT_VALIDITY
#: The ticket value
value = models.CharField(max_length=255, default=utils.gen_pgt, unique=True) value = models.CharField(max_length=255, default=utils.gen_pgt, unique=True)
def __str__(self): def __str__(self):
@ -635,10 +987,18 @@ class ProxyGrantingTicket(Ticket):
@python_2_unicode_compatible @python_2_unicode_compatible
class Proxy(models.Model): 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: class Meta:
ordering = ("-pk", ) ordering = ("-pk", )
#: Service url of the PGT used for getting the associated :class:`ProxyTicket`
url = models.CharField(max_length=255) 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") proxy_ticket = models.ForeignKey(ProxyTicket, related_name="proxies")
def __str__(self): def __str__(self):

View file

@ -1,36 +1,63 @@
{% extends 'bootstrap3/bootstrap3.html' %}
{% load i18n %} {% load i18n %}
{% block bootstrap3_title %}{% block title %}{% trans "Central Authentication Service" %}{% endblock %}{% endblock %}
{% load staticfiles %} {% load staticfiles %}
{% load bootstrap3 %} <!DOCTYPE html>
<html{% if request.LANGUAGE_CODE %} lang="{{ request.LANGUAGE_CODE }}"{% endif %}>
{% block bootstrap3_extra_head %} <head>
<link rel="shortcut icon" href="/static/cas_server/favicon.ico?v=1" /> <meta charset="utf-8">
<link href="{% static "cas_server/login.css" %}" rel="stylesheet"> <!--[if IE]><meta http-equiv="X-UA-Compatible" content="IE=edge" /><![endif]-->
{% endblock %} <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{% trans "Central Authentication Service" %}{% endblock %}</title>
{% block bootstrap3_content %} <link href="{{settings.CAS_COMPONENT_URLS.bootstrap3_css}}" rel="stylesheet">
<div class="container"> <!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
{% if auto_submit %}<noscript>{% endif %} <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
<div class="row"> <!--[if lt IE 9]>
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12"> <script src="{{settings.CAS_COMPONENT_URLS.html5shiv}}"></script>
<h1 id="app-name"> <script src="{{settings.CAS_COMPONENT_URLS.respond}}"></script>
{% if settings.CAS_LOGO_URL %}<img src="{{settings.CAS_LOGO_URL}}"></img> {% endif %} <![endif]-->
{% trans "Central Authentication Service" %}</h1> <link rel="shortcut icon" href="{% static "cas_server/favicon.ico?v=1" %}" />
</div> <link href="{% static "cas_server/login.css" %}" rel="stylesheet">
</div> </head>
{% if auto_submit %}</noscript>{% endif %} <body>
<div class="row"> <div class="container">
<div class="col-lg-3 col-md-3 col-sm-2 col-xs-12"></div> {% if auto_submit %}<noscript>{% endif %}
<div class="col-lg-6 col-md-6 col-sm-8 col-xs-12"> <div class="row">
{% if auto_submit %}<noscript>{% endif %} <div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
{% bootstrap_messages %} <h1 id="app-name">
{% if auto_submit %}</noscript>{% endif %} {% if settings.CAS_LOGO_URL %}<img src="{{settings.CAS_LOGO_URL}}"></img> {% endif %}
{% block content %} {% trans "Central Authentication Service" %}</h1>
{% endblock %} </div>
</div> </div>
<div class="col-lg-3 col-md-3 col-sm-2 col-xs-0"></div> {% if auto_submit %}</noscript>{% endif %}
</div> <div class="row">
</div> <!-- /container --> <div class="col-lg-3 col-md-3 col-sm-2 col-xs-12"></div>
{% endblock %} <div class="col-lg-6 col-md-6 col-sm-8 col-xs-12">
{% block ante_messages %}{% endblock %}
{% if auto_submit %}<noscript>{% endif %}
{% for message in messages %}
<div {% spaceless %}
{% if message.level == message_levels.DEBUG %}
class="alert alert-warning alert-dismissable"
{% elif message.level == message_levels.INFO %}
class="alert alert-info alert-dismissable"
{% elif message.level == message_levels.SUCCESS %}
class="alert alert-success alert-dismissable"
{% elif message.level == message_levels.WARNING %}
class="alert alert-warning alert-dismissable"
{% else %}
class="alert alert-danger alert-dismissable"
{% endif %}
{% endspaceless %}>
<button type="button" class="close" data-dismiss="alert" aria-hidden="true">&#215;</button>
{{ message }}
</div>
{% endfor %}
{% if auto_submit %}</noscript>{% endif %}
{% block content %}{% endblock %}
</div>
<div class="col-lg-3 col-md-3 col-sm-2 col-xs-0"></div>
</div>
</div> <!-- /container -->
<script src="{{settings.CAS_COMPONENT_URLS.jquery}}"></script>
<script src="{{settings.CAS_COMPONENT_URLS.bootstrap3_js}}"></script>
</body>
</html>

View file

@ -0,0 +1,25 @@
{% for error in form.non_field_errors %}
<div class="alert alert-danger alert-dismissable">
<button type="button" class="close" data-dismiss="alert" aria-hidden="true">&#215;</button>
{{error}}
</div>
{% endfor %}
{% for field in form %}{% if field.display %}
<div class="form-group{% spaceless %}
{% if not form.non_field_errors %}
{% if field.errors %} has-error
{% elif form.cleaned_data %} has-success
{% endif %}
{% endif %}"
{% endspaceless %}>{% spaceless %}
{% if field.checkbox %}
<div class="checkbox"><label for="{{field.auto_id}}">{{field}}{{field.label}}</label>
{% else %}
<label class="control-label" for="{{field.auto_id}}">{{field.label}}</label>
{{field}}
{% endif %}
{% for error in field.errors %}
<span class="help-block">{{error}}</span>
{% endfor %}
{% endspaceless %}</div>
{% else %}{{field}}{% endif %}{% endfor %}

View file

@ -1,6 +1,4 @@
{% extends "cas_server/base.html" %} {% extends "cas_server/base.html" %}
{% load bootstrap3 %}
{% load staticfiles %}
{% load i18n %} {% load i18n %}
{% block content %} {% block content %}
<div class="alert alert-success" role="alert">{% trans "Logged" %}</div> <div class="alert alert-success" role="alert">{% trans "Logged" %}</div>
@ -10,7 +8,7 @@
<input type="checkbox" name="all" value="1"> {% trans "Log me out from all my sessions" %} <input type="checkbox" name="all" value="1"> {% trans "Log me out from all my sessions" %}
</label> </label>
</div> </div>
{% bootstrap_button _('Logout') size='lg' button_type="submit" button_class="btn-danger btn-block"%} <button class="btn btn-danger btn-block btn-lg" type="submit">{% trans "Logout" %}</button>
</form> </form>
{% endblock %} {% endblock %}

View file

@ -1,18 +1,19 @@
{% extends "cas_server/base.html" %} {% extends "cas_server/base.html" %}
{% load bootstrap3 %}
{% load staticfiles %}
{% load i18n %} {% load i18n %}
{% block ante_messages %}
{% if auto_submit %}<noscript>{% endif %}
<h2 class="form-signin-heading">{% trans "Please log in" %}</h2>
{% if auto_submit %}</noscript>{% endif %}
{% endblock %}
{% block content %} {% block content %}
<form class="form-signin" method="post" id="login_form"{% if post_url %} action="{{post_url}}"{% endif %}> <form class="form-signin" method="post" id="login_form"{% if post_url %} action="{{post_url}}"{% endif %}>
{% if auto_submit %}<noscript>{% endif %} {% csrf_token %}
<h2 class="form-signin-heading">{% trans "Please log in" %}</h2> {% include "cas_server/form.html" %}
{% if auto_submit %}</noscript>{% endif %} {% if auto_submit %}<noscript>{% endif %}
{% csrf_token %} <button class="btn btn-primary btn-block btn-lg" type="submit">{% trans "Login" %}</button>
{% bootstrap_form form %} {% if auto_submit %}</noscript>{% endif %}
{% if auto_submit %}<noscript>{% endif %} </form>
{% bootstrap_button _('Login') size='lg' button_type="submit" button_class="btn-primary btn-block"%}
{% if auto_submit %}</noscript>{% endif %}
</form>
{% if auto_submit %} {% if auto_submit %}
<script type="text/javascript"> <script type="text/javascript">
document.getElementById('login_form').submit(); // SUBMIT FORM document.getElementById('login_form').submit(); // SUBMIT FORM

View file

@ -1,5 +1,4 @@
{% extends "cas_server/base.html" %} {% extends "cas_server/base.html" %}
{% load bootstrap3 %}
{% load staticfiles %} {% load staticfiles %}
{% load i18n %} {% load i18n %}
{% block content %} {% block content %}

View file

@ -1,12 +1,11 @@
{% extends "cas_server/base.html" %} {% extends "cas_server/base.html" %}
{% load bootstrap3 %}
{% load staticfiles %} {% load staticfiles %}
{% load i18n %} {% load i18n %}
{% block content %} {% block content %}
<form class="form-signin" method="post"> <form class="form-signin" method="post">
{% csrf_token %} {% csrf_token %}
{% bootstrap_form form %} {% include "cas_server/form.html" %}
{% bootstrap_button _('Connect to the service') size='lg' button_type="submit" button_class="btn-primary btn-block"%} <button class="btn btn-primary btn-block btn-lg" type="submit">{% trans "Connect to the service" %}</button>
</form> </form>
{% endblock %} {% endblock %}

View file

@ -37,7 +37,6 @@ INSTALLED_APPS = [
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'bootstrap3',
'cas_server', 'cas_server',
] ]

View file

@ -993,7 +993,7 @@ class ValidateTestCase(TestCase):
def test_validate_service_renew(self): def test_validate_service_renew(self):
"""test with a valid (ticket, service) asking for auth renewal""" """test with a valid (ticket, service) asking for auth renewal"""
# case 1 client is renewing and service ask for renew # case 1 client is renewing and service ask for renew
(client1, response) = get_auth_client(renew="True", service=self.service) response = get_auth_client(renew="True", service=self.service)[1]
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
ticket_value = response['Location'].split('ticket=')[-1] ticket_value = response['Location'].split('ticket=')[-1]
# get a bare client # get a bare client
@ -1112,7 +1112,9 @@ class ValidateServiceTestCase(TestCase, XmlContent):
name="localhost", name="localhost",
pattern="^https?://127\.0\.0\.1(:[0-9]+)?(/.*)?$", pattern="^https?://127\.0\.0\.1(:[0-9]+)?(/.*)?$",
# allow to request PGT by the service # allow to request PGT by the service
proxy_callback=True proxy_callback=True,
# allow to request PT for the service
proxy=True
) )
# tell the service pattern to transmit all the user attributes (* is a joker) # tell the service pattern to transmit all the user attributes (* is a joker)
models.ReplaceAttributName.objects.create(name="*", service_pattern=self.service_pattern) models.ReplaceAttributName.objects.create(name="*", service_pattern=self.service_pattern)
@ -1190,10 +1192,30 @@ class ValidateServiceTestCase(TestCase, XmlContent):
# the attributes settings.CAS_TEST_ATTRIBUTES # the attributes settings.CAS_TEST_ATTRIBUTES
self.assert_success(response, settings.CAS_TEST_USER, settings.CAS_TEST_ATTRIBUTES) self.assert_success(response, settings.CAS_TEST_USER, settings.CAS_TEST_ATTRIBUTES)
def test_validate_proxy(self):
"""test ProxyTicket validation on /proxyValidate and /serviceValidate"""
ticket = get_proxy_ticket(self.service)
client = Client()
# requesting validation with a good (ticket, service)
response = client.get('/proxyValidate', {'ticket': ticket.value, 'service': self.service})
# and it should succeed
self.assert_success(response, settings.CAS_TEST_USER, settings.CAS_TEST_ATTRIBUTES)
ticket = get_proxy_ticket(self.service)
client = Client()
# requesting validation with a good (ticket, service)
response = client.get('/serviceValidate', {'ticket': ticket.value, 'service': self.service})
# and it should succeed
self.assert_error(
response,
"INVALID_TICKET",
ticket.value
)
def test_validate_service_renew(self): def test_validate_service_renew(self):
"""test with a valid (ticket, service) asking for auth renewal""" """test with a valid (ticket, service) asking for auth renewal"""
# case 1 client is renewing and service ask for renew # case 1 client is renewing and service ask for renew
(client1, response) = get_auth_client(renew="True", service=self.service) response = get_auth_client(renew="True", service=self.service)[1]
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
ticket_value = response['Location'].split('ticket=')[-1] ticket_value = response['Location'].split('ticket=')[-1]
# get a bare client # get a bare client

View file

@ -10,14 +10,13 @@
# #
# (c) 2015-2016 Valentin Samir # (c) 2015-2016 Valentin Samir
"""urls for the app""" """urls for the app"""
from django.conf.urls import patterns, url from django.conf.urls import url
from django.views.generic import RedirectView from django.views.generic import RedirectView
from django.views.decorators.debug import sensitive_post_parameters, sensitive_variables from django.views.decorators.debug import sensitive_post_parameters, sensitive_variables
from cas_server import views from cas_server import views
urlpatterns = patterns( urlpatterns = [
'',
url(r'^$', RedirectView.as_view(pattern_name="cas_server:login")), url(r'^$', RedirectView.as_view(pattern_name="cas_server:login")),
url( url(
'^login$', '^login$',
@ -60,4 +59,4 @@ urlpatterns = patterns(
name='auth' name='auth'
), ),
url("^federate(?:/(?P<provider>([^/]+)))?$", views.FederateAuth.as_view(), name='federateAuth'), url("^federate(?:/(?P<provider>([^/]+)))?$", views.FederateAuth.as_view(), name='federateAuth'),
) ]

View file

@ -15,6 +15,8 @@ from .default_settings import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import HttpResponseRedirect, HttpResponse from django.http import HttpResponseRedirect, HttpResponse
from django.contrib import messages from django.contrib import messages
from django.contrib.messages import constants as DEFAULT_MESSAGE_LEVELS
from django.core.serializers.json import DjangoJSONEncoder
import random import random
import string import string
@ -29,14 +31,38 @@ from datetime import datetime, timedelta
from six.moves.urllib.parse import urlparse, urlunparse, parse_qsl, urlencode from six.moves.urllib.parse import urlparse, urlunparse, parse_qsl, urlencode
def json_encode(obj):
"""Encode a python object to json"""
try:
return json_encode.encoder.encode(obj)
except AttributeError:
json_encode.encoder = DjangoJSONEncoder(default=six.text_type)
return json_encode(obj)
def context(params): def context(params):
"""Function that add somes variable to the context before template rendering""" """
Function that add somes variable to the context before template rendering
:param dict params: The context dictionary used to render templates.
:return: The ``params`` dictionary with the key ``settings`` set to
:obj:`django.conf.settings`.
:rtype: dict
"""
params["settings"] = settings params["settings"] = settings
params["message_levels"] = DEFAULT_MESSAGE_LEVELS
return params return params
def json_response(request, data): def json_response(request, data):
"""Wrapper dumping `data` to a json and sending it to the user with an HttpResponse""" """
Wrapper dumping `data` to a json and sending it to the user with an HttpResponse
:param django.http.HttpRequest request: The request object used to generate this response.
:param dict data: The python dictionnary to return as a json
:return: The content of ``data`` serialized in json
:rtype: django.http.HttpResponse
"""
data["messages"] = [] data["messages"] = []
for msg in messages.get_messages(request): for msg in messages.get_messages(request):
data["messages"].append({'message': msg.message, 'level': msg.level_tag}) data["messages"].append({'message': msg.message, 'level': msg.level_tag})
@ -44,7 +70,13 @@ def json_response(request, data):
def import_attr(path): def import_attr(path):
"""transform a python module.attr path to the attr""" """
transform a python dotted path to the attr
:param path: A dotted path to a python object or a python object
:type path: :obj:`unicode` or anything
:return: The python object pointed by the dotted path or the python object unchanged
"""
if not isinstance(path, str): if not isinstance(path, str):
return path return path
if "." not in path: if "." not in path:
@ -59,24 +91,50 @@ def import_attr(path):
def redirect_params(url_name, params=None): def redirect_params(url_name, params=None):
"""Redirect to `url_name` with `params` as querystring""" """
Redirect to ``url_name`` with ``params`` as querystring
:param unicode url_name: a URL pattern name
:param params: Some parameter to append to the reversed URL
:type params: :obj:`dict` or :obj:`NoneType<types.NoneType>`
:return: A redirection to the URL with name ``url_name`` with ``params`` as querystring.
:rtype: django.http.HttpResponseRedirect
"""
url = reverse(url_name) url = reverse(url_name)
params = urlencode(params if params else {}) params = urlencode(params if params else {})
return HttpResponseRedirect(url + "?%s" % params) return HttpResponseRedirect(url + "?%s" % params)
def reverse_params(url_name, params=None, **kwargs): def reverse_params(url_name, params=None, **kwargs):
"""compule the reverse url or `url_name` and add GET parameters from `params` to it""" """
compute the reverse url of ``url_name`` and add to it parameters from ``params``
as querystring
:param unicode url_name: a URL pattern name
:param params: Some parameter to append to the reversed URL
:type params: :obj:`dict` or :obj:`NoneType<types.NoneType>`
:param **kwargs: additional parameters needed to compure the reverse URL
:return: The computed reverse URL of ``url_name`` with possible querystring from ``params``
:rtype: unicode
"""
url = reverse(url_name, **kwargs) url = reverse(url_name, **kwargs)
params = urlencode(params if params else {}) params = urlencode(params if params else {})
if params: if params:
return url + "?%s" % params return u"%s?%s" % (url, params)
else: else:
return url return url
def copy_params(get_or_post_params, ignore=None): def copy_params(get_or_post_params, ignore=None):
"""copy from a dictionnary like `get_or_post_params` ignoring keys in the set `ignore`""" """
copy a :class:`django.http.QueryDict` in a :obj:`dict` ignoring keys in the set ``ignore``
:param django.http.QueryDict get_or_post_params: A GET or POST
:class:`QueryDict<django.http.QueryDict>`
:param set ignore: An optinal set of keys to ignore during the copy
:return: A copy of get_or_post_params
:rtype: dict
"""
if ignore is None: if ignore is None:
ignore = set() ignore = set()
params = {} params = {}
@ -87,7 +145,14 @@ def copy_params(get_or_post_params, ignore=None):
def set_cookie(response, key, value, max_age): def set_cookie(response, key, value, max_age):
"""Set the cookie `key` on `response` with value `value` valid for `max_age` secondes""" """
Set the cookie ``key`` on ``response`` with value ``value`` valid for ``max_age`` secondes
:param django.http.HttpResponse response: a django response where to set the cookie
:param unicode key: the cookie key
:param unicode value: the cookie value
:param int max_age: the maximum validity age of the cookie
"""
expires = datetime.strftime( expires = datetime.strftime(
datetime.utcnow() + timedelta(seconds=max_age), datetime.utcnow() + timedelta(seconds=max_age),
"%a, %d-%b-%Y %H:%M:%S GMT" "%a, %d-%b-%Y %H:%M:%S GMT"
@ -103,20 +168,36 @@ def set_cookie(response, key, value, max_age):
def get_current_url(request, ignore_params=None): def get_current_url(request, ignore_params=None):
"""Giving a django request, return the current http url, possibly ignoring some GET params""" """
Giving a django request, return the current http url, possibly ignoring some GET parameters
:param django.http.HttpRequest request: The current request object.
:param set ignore_params: An optional set of GET parameters to ignore
:return: The URL of the current page, possibly omitting some parameters from
``ignore_params`` in the querystring.
:rtype: unicode
"""
if ignore_params is None: if ignore_params is None:
ignore_params = set() ignore_params = set()
protocol = 'https' if request.is_secure() else "http" protocol = u'https' if request.is_secure() else u"http"
service_url = "%s://%s%s" % (protocol, request.get_host(), request.path) service_url = u"%s://%s%s" % (protocol, request.get_host(), request.path)
if request.GET: if request.GET:
params = copy_params(request.GET, ignore_params) params = copy_params(request.GET, ignore_params)
if params: if params:
service_url += "?%s" % urlencode(params) service_url += u"?%s" % urlencode(params)
return service_url return service_url
def update_url(url, params): def update_url(url, params):
"""update params in the `url` query string""" """
update parameters using ``params`` in the ``url`` query string
:param url: An URL possibily with a querystring
:type url: :obj:`unicode` or :obj:`str`
:param dict params: A dictionary of parameters for updating the url querystring
:return: The URL with an updated querystring
:rtype: unicode
"""
if not isinstance(url, bytes): if not isinstance(url, bytes):
url = url.encode('utf-8') url = url.encode('utf-8')
for key, value in list(params.items()): for key, value in list(params.items()):
@ -140,7 +221,12 @@ def update_url(url, params):
def unpack_nested_exception(error): def unpack_nested_exception(error):
"""If exception are stacked, return the first one""" """
If exception are stacked, return the first one
:param error: A python exception with possible exception embeded within
:return: A python exception with no exception embeded within
"""
i = 0 i = 0
while True: while True:
if error.args[i:]: if error.args[i:]:
@ -154,52 +240,97 @@ def unpack_nested_exception(error):
return error return error
def _gen_ticket(prefix, lg=settings.CAS_TICKET_LEN): def _gen_ticket(prefix=None, lg=settings.CAS_TICKET_LEN):
"""Generate a ticket with prefix `prefix`""" """
return '%s-%s' % ( Generate a ticket with prefix ``prefix`` and length ``lg``
prefix,
''.join( :param unicode prefix: An optional prefix (probably ST, PT, PGT or PGTIOU)
random.choice( :param int lg: The length of the generated ticket (with the prefix)
string.ascii_letters + string.digits :return: A randomlly generated ticket of length ``lg``
) for _ in range(lg - len(prefix) - 1) :rtype: unicode
) """
random_part = u''.join(
random.choice(
string.ascii_letters + string.digits
) for _ in range(lg - len(prefix or "") - 1)
) )
if prefix is not None:
return u'%s-%s' % (prefix, random_part)
else:
return random_part
def gen_lt(): def gen_lt():
"""Generate a Service Ticket""" """
Generate a Login Ticket
:return: A ticket with prefix ``settings.CAS_LOGIN_TICKET_PREFIX`` and length
``settings.CAS_LT_LEN``
:rtype: unicode
"""
return _gen_ticket(settings.CAS_LOGIN_TICKET_PREFIX, settings.CAS_LT_LEN) return _gen_ticket(settings.CAS_LOGIN_TICKET_PREFIX, settings.CAS_LT_LEN)
def gen_st(): def gen_st():
"""Generate a Service Ticket""" """
Generate a Service Ticket
:return: A ticket with prefix ``settings.CAS_SERVICE_TICKET_PREFIX`` and length
``settings.CAS_ST_LEN``
:rtype: unicode
"""
return _gen_ticket(settings.CAS_SERVICE_TICKET_PREFIX, settings.CAS_ST_LEN) return _gen_ticket(settings.CAS_SERVICE_TICKET_PREFIX, settings.CAS_ST_LEN)
def gen_pt(): def gen_pt():
"""Generate a Proxy Ticket""" """
Generate a Proxy Ticket
:return: A ticket with prefix ``settings.CAS_PROXY_TICKET_PREFIX`` and length
``settings.CAS_PT_LEN``
:rtype: unicode
"""
return _gen_ticket(settings.CAS_PROXY_TICKET_PREFIX, settings.CAS_PT_LEN) return _gen_ticket(settings.CAS_PROXY_TICKET_PREFIX, settings.CAS_PT_LEN)
def gen_pgt(): def gen_pgt():
"""Generate a Proxy Granting Ticket""" """
Generate a Proxy Granting Ticket
:return: A ticket with prefix ``settings.CAS_PROXY_GRANTING_TICKET_PREFIX`` and length
``settings.CAS_PGT_LEN``
:rtype: unicode
"""
return _gen_ticket(settings.CAS_PROXY_GRANTING_TICKET_PREFIX, settings.CAS_PGT_LEN) return _gen_ticket(settings.CAS_PROXY_GRANTING_TICKET_PREFIX, settings.CAS_PGT_LEN)
def gen_pgtiou(): def gen_pgtiou():
"""Generate a Proxy Granting Ticket IOU""" """
Generate a Proxy Granting Ticket IOU
:return: A ticket with prefix ``settings.CAS_PROXY_GRANTING_TICKET_IOU_PREFIX`` and length
``settings.CAS_PGTIOU_LEN``
:rtype: unicode
"""
return _gen_ticket(settings.CAS_PROXY_GRANTING_TICKET_IOU_PREFIX, settings.CAS_PGTIOU_LEN) return _gen_ticket(settings.CAS_PROXY_GRANTING_TICKET_IOU_PREFIX, settings.CAS_PGTIOU_LEN)
def gen_saml_id(): def gen_saml_id():
"""Generate an saml id""" """
return _gen_ticket('_') Generate an saml id
:return: A random id of length ``settings.CAS_TICKET_LEN``
:rtype: unicode
"""
return _gen_ticket()
def get_tuple(nuplet, index, default=None): def get_tuple(nuplet, index, default=None):
""" """
return the value in index `index` of the tuple `nuplet` if it exists, :param tuple nuplet: A tuple
else return `default` :param int index: An index
:param default: An optional default value
:return: ``nuplet[index]`` if defined, else ``default`` (possibly ``None``)
""" """
if nuplet is None: if nuplet is None:
return default return default
@ -210,7 +341,13 @@ def get_tuple(nuplet, index, default=None):
def crypt_salt_is_valid(salt): def crypt_salt_is_valid(salt):
"""Return True is salt is valid has a crypt salt, False otherwise""" """
Validate a salt as crypt salt
:param str salt: a password salt
:return: ``True`` if ``salt`` is a valid crypt salt on this system, ``False`` otherwise
:rtype: bool
"""
if len(salt) < 2: if len(salt) < 2:
return False return False
else: else:
@ -231,11 +368,17 @@ def crypt_salt_is_valid(salt):
class LdapHashUserPassword(object): class LdapHashUserPassword(object):
"""Please see https://tools.ietf.org/id/draft-stroeder-hashed-userpassword-values-01.html""" """
Class to deal with hashed password as defined at
https://tools.ietf.org/id/draft-stroeder-hashed-userpassword-values-01.html
"""
#: valide schemes that require a salt
schemes_salt = {b"{SMD5}", b"{SSHA}", b"{SSHA256}", b"{SSHA384}", b"{SSHA512}", b"{CRYPT}"} schemes_salt = {b"{SMD5}", b"{SSHA}", b"{SSHA256}", b"{SSHA384}", b"{SSHA512}", b"{CRYPT}"}
#: valide sschemes that require no slat
schemes_nosalt = {b"{MD5}", b"{SHA}", b"{SHA256}", b"{SHA384}", b"{SHA512}"} schemes_nosalt = {b"{MD5}", b"{SHA}", b"{SHA256}", b"{SHA384}", b"{SHA512}"}
#: map beetween scheme and hash function
_schemes_to_hash = { _schemes_to_hash = {
b"{SMD5}": hashlib.md5, b"{SMD5}": hashlib.md5,
b"{MD5}": hashlib.md5, b"{MD5}": hashlib.md5,
@ -249,6 +392,7 @@ class LdapHashUserPassword(object):
b"{SHA512}": hashlib.sha512 b"{SHA512}": hashlib.sha512
} }
#: map between scheme and hash length
_schemes_to_len = { _schemes_to_len = {
b"{SMD5}": 16, b"{SMD5}": 16,
b"{SSHA}": 20, b"{SSHA}": 20,
@ -258,7 +402,10 @@ class LdapHashUserPassword(object):
} }
class BadScheme(ValueError): class BadScheme(ValueError):
"""Error raised then the hash scheme is not in schemes_salt + schemes_nosalt""" """
Error raised then the hash scheme is not in
:attr:`LdapHashUserPassword.schemes_salt` + :attr:`LdapHashUserPassword.schemes_nosalt`
"""
pass pass
class BadHash(ValueError): class BadHash(ValueError):
@ -266,14 +413,19 @@ class LdapHashUserPassword(object):
pass pass
class BadSalt(ValueError): class BadSalt(ValueError):
"""Error raised then with the scheme {CRYPT} the salt is invalid""" """Error raised then, with the scheme ``{CRYPT}``, the salt is invalid"""
pass pass
@classmethod @classmethod
def _raise_bad_scheme(cls, scheme, valid, msg): def _raise_bad_scheme(cls, scheme, valid, msg):
""" """
Raise BadScheme error for `scheme`, possible valid scheme are Raise :attr:`BadScheme` error for ``scheme``, possible valid scheme are
in `valid`, the error message is `msg` in ``valid``, the error message is ``msg``
:param bytes scheme: A bad scheme
:param list valid: A list a valid scheme
:param str msg: The error template message
:raises LdapHashUserPassword.BadScheme: always
""" """
valid_schemes = [s.decode() for s in valid] valid_schemes = [s.decode() for s in valid]
valid_schemes.sort() valid_schemes.sort()
@ -281,7 +433,12 @@ class LdapHashUserPassword(object):
@classmethod @classmethod
def _test_scheme(cls, scheme): def _test_scheme(cls, scheme):
"""Test if a scheme is valide or raise BadScheme""" """
Test if a scheme is valide or raise BadScheme
:param bytes scheme: A scheme
:raises BadScheme: if ``scheme`` is not a valid scheme
"""
if scheme not in cls.schemes_salt and scheme not in cls.schemes_nosalt: if scheme not in cls.schemes_salt and scheme not in cls.schemes_nosalt:
cls._raise_bad_scheme( cls._raise_bad_scheme(
scheme, scheme,
@ -291,7 +448,12 @@ class LdapHashUserPassword(object):
@classmethod @classmethod
def _test_scheme_salt(cls, scheme): def _test_scheme_salt(cls, scheme):
"""Test if the scheme need a salt or raise BadScheme""" """
Test if the scheme need a salt or raise BadScheme
:param bytes scheme: A scheme
:raises BadScheme: if ``scheme` require no salt
"""
if scheme not in cls.schemes_salt: if scheme not in cls.schemes_salt:
cls._raise_bad_scheme( cls._raise_bad_scheme(
scheme, scheme,
@ -301,7 +463,12 @@ class LdapHashUserPassword(object):
@classmethod @classmethod
def _test_scheme_nosalt(cls, scheme): def _test_scheme_nosalt(cls, scheme):
"""Test if the scheme need no salt or raise BadScheme""" """
Test if the scheme need no salt or raise BadScheme
:param bytes scheme: A scheme
:raises BadScheme: if ``scheme` require a salt
"""
if scheme not in cls.schemes_nosalt: if scheme not in cls.schemes_nosalt:
cls._raise_bad_scheme( cls._raise_bad_scheme(
scheme, scheme,
@ -312,8 +479,15 @@ class LdapHashUserPassword(object):
@classmethod @classmethod
def hash(cls, scheme, password, salt=None, charset="utf8"): def hash(cls, scheme, password, salt=None, charset="utf8"):
""" """
Hash `password` with `scheme` using `salt`. Hash ``password`` with ``scheme`` using ``salt``.
This three variable beeing encoded in `charset`. This three variable beeing encoded in ``charset``.
:param bytes scheme: A valid scheme
:param bytes password: A byte string to hash using ``scheme``
:param bytes salt: An optional salt to use if ``scheme`` requires any
:param str charset: The encoding of ``scheme``, ``password`` and ``salt``
:return: The hashed password encoded with ``charset``
:rtype: bytes
""" """
scheme = scheme.upper() scheme = scheme.upper()
cls._test_scheme(scheme) cls._test_scheme(scheme)
@ -339,7 +513,14 @@ class LdapHashUserPassword(object):
@classmethod @classmethod
def get_scheme(cls, hashed_passord): def get_scheme(cls, hashed_passord):
"""Return the scheme of `hashed_passord` or raise BadHash""" """
Return the scheme of ``hashed_passord`` or raise :attr:`BadHash`
:param bytes hashed_passord: A hashed password
:return: The scheme used by the hashed password
:rtype: bytes
:raises BadHash: if no valid scheme is found within ``hashed_passord``
"""
if not hashed_passord[0] == b'{'[0] or b'}' not in hashed_passord: if not hashed_passord[0] == b'{'[0] or b'}' not in hashed_passord:
raise cls.BadHash("%r should start with the scheme enclosed with { }" % hashed_passord) raise cls.BadHash("%r should start with the scheme enclosed with { }" % hashed_passord)
scheme = hashed_passord.split(b'}', 1)[0] scheme = hashed_passord.split(b'}', 1)[0]
@ -348,7 +529,15 @@ class LdapHashUserPassword(object):
@classmethod @classmethod
def get_salt(cls, hashed_passord): def get_salt(cls, hashed_passord):
"""Return the salt of `hashed_passord` possibly empty""" """
Return the salt of ``hashed_passord`` possibly empty
:param bytes hashed_passord: A hashed password
:return: The salt used by the hashed password (empty if no salt is used)
:rtype: bytes
:raises BadHash: if no valid scheme is found within ``hashed_passord`` or if the
hashed password is too short for the scheme found.
"""
scheme = cls.get_scheme(hashed_passord) scheme = cls.get_scheme(hashed_passord)
cls._test_scheme(scheme) cls._test_scheme(scheme)
if scheme in cls.schemes_nosalt: if scheme in cls.schemes_nosalt:
@ -364,8 +553,20 @@ class LdapHashUserPassword(object):
def check_password(method, password, hashed_password, charset): def check_password(method, password, hashed_password, charset):
""" """
Check that `password` match `hashed_password` using `method`, Check that ``password`` match `hashed_password` using ``method``,
assuming the encoding is `charset`. assuming the encoding is ``charset``.
:param str method: on of ``"crypt"``, ``"ldap"``, ``"hex_md5"``, ``"hex_sha1"``,
``"hex_sha224"``, ``"hex_sha256"``, ``"hex_sha384"``, ``"hex_sha512"``, ``"plain"``
:param password: The user inputed password
:type password: :obj:`str` or :obj:`unicode`
: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`)
:return: True if ``password`` match ``hashed_password`` using ``method``,
``False`` otherwise
:rtype: bool
""" """
if not isinstance(password, six.binary_type): if not isinstance(password, six.binary_type):
password = password.encode(charset) password = password.encode(charset)

File diff suppressed because it is too large Load diff

225
docs/Makefile Normal file
View file

@ -0,0 +1,225 @@
# Makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
PAPER =
BUILDDIR = _build
# Internal variables.
PAPEROPT_a4 = -D latex_paper_size=a4
PAPEROPT_letter = -D latex_paper_size=letter
ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
# the i18n builder cannot share the environment and doctrees with the others
I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
.PHONY: help
help:
@echo "Please use \`make <target>' where <target> is one of"
@echo " html to make standalone HTML files"
@echo " dirhtml to make HTML files named index.html in directories"
@echo " singlehtml to make a single large HTML file"
@echo " pickle to make pickle files"
@echo " json to make JSON files"
@echo " htmlhelp to make HTML files and a HTML help project"
@echo " qthelp to make HTML files and a qthelp project"
@echo " applehelp to make an Apple Help Book"
@echo " devhelp to make HTML files and a Devhelp project"
@echo " epub to make an epub"
@echo " epub3 to make an epub3"
@echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
@echo " latexpdf to make LaTeX files and run them through pdflatex"
@echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
@echo " text to make text files"
@echo " man to make manual pages"
@echo " texinfo to make Texinfo files"
@echo " info to make Texinfo files and run them through makeinfo"
@echo " gettext to make PO message catalogs"
@echo " changes to make an overview of all changed/added/deprecated items"
@echo " xml to make Docutils-native XML files"
@echo " pseudoxml to make pseudoxml-XML files for display purposes"
@echo " linkcheck to check all external links for integrity"
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
@echo " coverage to run coverage check of the documentation (if enabled)"
@echo " dummy to check syntax errors of document sources"
.PHONY: clean
clean:
rm -rf $(BUILDDIR)/*
.PHONY: html
html:
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
.PHONY: dirhtml
dirhtml:
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
.PHONY: singlehtml
singlehtml:
$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
@echo
@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
.PHONY: pickle
pickle:
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
@echo
@echo "Build finished; now you can process the pickle files."
.PHONY: json
json:
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
@echo
@echo "Build finished; now you can process the JSON files."
.PHONY: htmlhelp
htmlhelp:
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
@echo
@echo "Build finished; now you can run HTML Help Workshop with the" \
".hhp project file in $(BUILDDIR)/htmlhelp."
.PHONY: qthelp
qthelp:
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
@echo
@echo "Build finished; now you can run "qcollectiongenerator" with the" \
".qhcp project file in $(BUILDDIR)/qthelp, like this:"
@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-cas-server.qhcp"
@echo "To view the help file:"
@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-cas-server.qhc"
.PHONY: applehelp
applehelp:
$(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp
@echo
@echo "Build finished. The help book is in $(BUILDDIR)/applehelp."
@echo "N.B. You won't be able to view it unless you put it in" \
"~/Library/Documentation/Help or install it in your application" \
"bundle."
.PHONY: devhelp
devhelp:
$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
@echo
@echo "Build finished."
@echo "To view the help file:"
@echo "# mkdir -p $$HOME/.local/share/devhelp/django-cas-server"
@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-cas-server"
@echo "# devhelp"
.PHONY: epub
epub:
$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
@echo
@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
.PHONY: epub3
epub3:
$(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3
@echo
@echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3."
.PHONY: latex
latex:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo
@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
@echo "Run \`make' in that directory to run these through (pdf)latex" \
"(use \`make latexpdf' here to do that automatically)."
.PHONY: latexpdf
latexpdf:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo "Running LaTeX files through pdflatex..."
$(MAKE) -C $(BUILDDIR)/latex all-pdf
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
.PHONY: latexpdfja
latexpdfja:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo "Running LaTeX files through platex and dvipdfmx..."
$(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
.PHONY: text
text:
$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
@echo
@echo "Build finished. The text files are in $(BUILDDIR)/text."
.PHONY: man
man:
$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
@echo
@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
.PHONY: texinfo
texinfo:
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo
@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
@echo "Run \`make' in that directory to run these through makeinfo" \
"(use \`make info' here to do that automatically)."
.PHONY: info
info:
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo "Running Texinfo files through makeinfo..."
make -C $(BUILDDIR)/texinfo info
@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
.PHONY: gettext
gettext:
$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
@echo
@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
.PHONY: changes
changes:
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
@echo
@echo "The overview file is in $(BUILDDIR)/changes."
.PHONY: linkcheck
linkcheck:
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
@echo
@echo "Link check complete; look for any errors in the above output " \
"or in $(BUILDDIR)/linkcheck/output.txt."
.PHONY: doctest
doctest:
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
@echo "Testing of doctests in the sources finished, look at the " \
"results in $(BUILDDIR)/doctest/output.txt."
.PHONY: coverage
coverage:
$(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage
@echo "Testing of coverage in the sources finished, look at the " \
"results in $(BUILDDIR)/coverage/python.txt."
.PHONY: xml
xml:
$(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
@echo
@echo "Build finished. The XML files are in $(BUILDDIR)/xml."
.PHONY: pseudoxml
pseudoxml:
$(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
@echo
@echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
.PHONY: dummy
dummy:
$(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy
@echo
@echo "Build finished. Dummy builder generates no files."

1
docs/README.rst Normal file
View file

@ -0,0 +1 @@
.. include:: ../README.rst

321
docs/_ext/djangodocs.py Normal file
View file

@ -0,0 +1,321 @@
"""
Sphinx plugins for Django documentation.
"""
import json
import os
import re
from docutils import nodes
from docutils.parsers.rst import directives
from sphinx import addnodes
from sphinx.builders.html import StandaloneHTMLBuilder
from sphinx.domains.std import Cmdoption
from sphinx.util.compat import Directive
from sphinx.util.console import bold
from sphinx.util.nodes import set_source_info
from sphinx.writers.html import SmartyPantsHTMLTranslator
# RE for option descriptions without a '--' prefix
simple_option_desc_re = re.compile(
r'([-_a-zA-Z0-9]+)(\s*.*?)(?=,\s+(?:/|-|--)|$)')
def setup(app):
app.add_crossref_type(
directivename="setting",
rolename="setting",
indextemplate="pair: %s; setting",
)
app.add_crossref_type(
directivename="templatetag",
rolename="ttag",
indextemplate="pair: %s; template tag"
)
app.add_crossref_type(
directivename="templatefilter",
rolename="tfilter",
indextemplate="pair: %s; template filter"
)
app.add_crossref_type(
directivename="fieldlookup",
rolename="lookup",
indextemplate="pair: %s; field lookup type",
)
app.add_description_unit(
directivename="django-admin",
rolename="djadmin",
indextemplate="pair: %s; django-admin command",
parse_node=parse_django_admin_node,
)
app.add_directive('django-admin-option', Cmdoption)
app.add_config_value('django_next_version', '0.0', True)
app.add_directive('versionadded', VersionDirective)
app.add_directive('versionchanged', VersionDirective)
app.add_builder(DjangoStandaloneHTMLBuilder)
# register the snippet directive
app.add_directive('snippet', SnippetWithFilename)
# register a node for snippet directive so that the xml parser
# knows how to handle the enter/exit parsing event
app.add_node(snippet_with_filename,
html=(visit_snippet, depart_snippet_literal),
latex=(visit_snippet_latex, depart_snippet_latex),
man=(visit_snippet_literal, depart_snippet_literal),
text=(visit_snippet_literal, depart_snippet_literal),
texinfo=(visit_snippet_literal, depart_snippet_literal))
return {'parallel_read_safe': True}
class snippet_with_filename(nodes.literal_block):
"""
Subclass the literal_block to override the visit/depart event handlers
"""
pass
def visit_snippet_literal(self, node):
"""
default literal block handler
"""
self.visit_literal_block(node)
def depart_snippet_literal(self, node):
"""
default literal block handler
"""
self.depart_literal_block(node)
def visit_snippet(self, node):
"""
HTML document generator visit handler
"""
lang = self.highlightlang
linenos = node.rawsource.count('\n') >= self.highlightlinenothreshold - 1
fname = node['filename']
highlight_args = node.get('highlight_args', {})
if 'language' in node:
# code-block directives
lang = node['language']
highlight_args['force'] = True
if 'linenos' in node:
linenos = node['linenos']
def warner(msg):
self.builder.warn(msg, (self.builder.current_docname, node.line))
highlighted = self.highlighter.highlight_block(node.rawsource, lang,
warn=warner,
linenos=linenos,
**highlight_args)
starttag = self.starttag(node, 'div', suffix='',
CLASS='highlight-%s snippet' % lang)
self.body.append(starttag)
self.body.append('<div class="snippet-filename">%s</div>\n''' % (fname,))
self.body.append(highlighted)
self.body.append('</div>\n')
raise nodes.SkipNode
def visit_snippet_latex(self, node):
"""
Latex document generator visit handler
"""
code = node.rawsource.rstrip('\n')
lang = self.hlsettingstack[-1][0]
linenos = code.count('\n') >= self.hlsettingstack[-1][1] - 1
fname = node['filename']
highlight_args = node.get('highlight_args', {})
if 'language' in node:
# code-block directives
lang = node['language']
highlight_args['force'] = True
if 'linenos' in node:
linenos = node['linenos']
def warner(msg):
self.builder.warn(msg, (self.curfilestack[-1], node.line))
hlcode = self.highlighter.highlight_block(code, lang, warn=warner,
linenos=linenos,
**highlight_args)
self.body.append(
'\n{\\colorbox[rgb]{0.9,0.9,0.9}'
'{\\makebox[\\textwidth][l]'
'{\\small\\texttt{%s}}}}\n' % (
# Some filenames have '_', which is special in latex.
fname.replace('_', r'\_'),
)
)
if self.table:
hlcode = hlcode.replace('\\begin{Verbatim}',
'\\begin{OriginalVerbatim}')
self.table.has_problematic = True
self.table.has_verbatim = True
hlcode = hlcode.rstrip()[:-14] # strip \end{Verbatim}
hlcode = hlcode.rstrip() + '\n'
self.body.append('\n' + hlcode + '\\end{%sVerbatim}\n' %
(self.table and 'Original' or ''))
# Prevent rawsource from appearing in output a second time.
raise nodes.SkipNode
def depart_snippet_latex(self, node):
"""
Latex document generator depart handler.
"""
pass
class SnippetWithFilename(Directive):
"""
The 'snippet' directive that allows to add the filename (optional)
of a code snippet in the document. This is modeled after CodeBlock.
"""
has_content = True
optional_arguments = 1
option_spec = {'filename': directives.unchanged_required}
def run(self):
code = '\n'.join(self.content)
literal = snippet_with_filename(code, code)
if self.arguments:
literal['language'] = self.arguments[0]
literal['filename'] = self.options['filename']
set_source_info(self, literal)
return [literal]
class VersionDirective(Directive):
has_content = True
required_arguments = 1
optional_arguments = 1
final_argument_whitespace = True
option_spec = {}
def run(self):
if len(self.arguments) > 1:
msg = """Only one argument accepted for directive '{directive_name}::'.
Comments should be provided as content,
not as an extra argument.""".format(directive_name=self.name)
raise self.error(msg)
env = self.state.document.settings.env
ret = []
node = addnodes.versionmodified()
ret.append(node)
if self.arguments[0] == env.config.django_next_version:
node['version'] = "Development version"
else:
node['version'] = self.arguments[0]
node['type'] = self.name
if self.content:
self.state.nested_parse(self.content, self.content_offset, node)
env.note_versionchange(node['type'], node['version'], node, self.lineno)
return ret
class DjangoHTMLTranslator(SmartyPantsHTMLTranslator):
"""
Django-specific reST to HTML tweaks.
"""
# Don't use border=1, which docutils does by default.
def visit_table(self, node):
self.context.append(self.compact_p)
self.compact_p = True
self._table_row_index = 0 # Needed by Sphinx
self.body.append(self.starttag(node, 'table', CLASS='docutils'))
def depart_table(self, node):
self.compact_p = self.context.pop()
self.body.append('</table>\n')
def visit_desc_parameterlist(self, node):
self.body.append('(') # by default sphinx puts <big> around the "("
self.first_param = 1
self.optional_param_level = 0
self.param_separator = node.child_text_separator
self.required_params_left = sum([isinstance(c, addnodes.desc_parameter)
for c in node.children])
def depart_desc_parameterlist(self, node):
self.body.append(')')
#
# Turn the "new in version" stuff (versionadded/versionchanged) into a
# better callout -- the Sphinx default is just a little span,
# which is a bit less obvious that I'd like.
#
# FIXME: these messages are all hardcoded in English. We need to change
# that to accommodate other language docs, but I can't work out how to make
# that work.
#
version_text = {
'versionchanged': 'Changed in Django %s',
'versionadded': 'New in Django %s',
}
def visit_versionmodified(self, node):
self.body.append(
self.starttag(node, 'div', CLASS=node['type'])
)
version_text = self.version_text.get(node['type'])
if version_text:
title = "%s%s" % (
version_text % node['version'],
":" if len(node) else "."
)
self.body.append('<span class="title">%s</span> ' % title)
def depart_versionmodified(self, node):
self.body.append("</div>\n")
# Give each section a unique ID -- nice for custom CSS hooks
def visit_section(self, node):
old_ids = node.get('ids', [])
node['ids'] = ['s-' + i for i in old_ids]
node['ids'].extend(old_ids)
SmartyPantsHTMLTranslator.visit_section(self, node)
node['ids'] = old_ids
def parse_django_admin_node(env, sig, signode):
command = sig.split(' ')[0]
env.ref_context['std:program'] = command
title = "django-admin %s" % sig
signode += addnodes.desc_name(title, title)
return command
class DjangoStandaloneHTMLBuilder(StandaloneHTMLBuilder):
"""
Subclass to add some extra things we need.
"""
name = 'djangohtml'
def finish(self):
super(DjangoStandaloneHTMLBuilder, self).finish()
self.info(bold("writing templatebuiltins.js..."))
xrefs = self.env.domaindata["std"]["objects"]
templatebuiltins = {
"ttags": [n for ((t, n), (l, a)) in xrefs.items()
if t == "templatetag" and l == "ref/templates/builtins"],
"tfilters": [n for ((t, n), (l, a)) in xrefs.items()
if t == "templatefilter" and l == "ref/templates/builtins"],
}
outfilename = os.path.join(self.outdir, "templatebuiltins.js")
with open(outfilename, 'w') as fp:
fp.write('var django_template_builtins = ')
json.dump(templatebuiltins, fp)
fp.write(';\n')

369
docs/conf.py Normal file
View file

@ -0,0 +1,369 @@
# -*- coding: utf-8 -*-
#
# django-cas-server documentation build configuration file, created by
# sphinx-quickstart on Tue Jul 5 12:11:50 2016.
#
# This file is execfile()d with the current directory set to its
# containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
import os
import sys
sys.path.insert(0, os.path.abspath('.'))
sys.path.append(os.path.abspath('..'))
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "_ext")))
import setup as mysetup
os.environ['DJANGO_SETTINGS_MODULE'] = 'cas_server.tests.settings'
import django
django.setup()
# -- General configuration ------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#
# needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'djangodocs',
'sphinx.ext.autodoc',
'sphinx.ext.doctest',
'sphinx.ext.intersphinx',
'sphinx.ext.coverage',
'sphinx.ext.viewcode',
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
source_suffix = '.rst'
# The encoding of source files.
#
# source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = u'django-cas-server'
copyright = u'2016, Valentin Samir'
author = u'Valentin Samir'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = mysetup.VERSION
# The full version, including alpha/beta/rc tags.
release = version
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
#
# today = ''
#
# Else, today_fmt is used as the format for a strftime call.
#
# today_fmt = '%B %d, %Y'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This patterns also effect to html_static_path and html_extra_path
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
# The reST default role (used for this markup: `text`) to use for all
# documents.
#
# default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
#
# add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
#
# add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
#
# show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
# modindex_common_prefix = []
# If true, keep warnings as "system message" paragraphs in the built documents.
# keep_warnings = False
# If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = False
# -- Options for HTML output ----------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
#html_theme = 'alabaster'
html_theme = 'sphinx_rtd_theme'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#
# html_theme_options = {}
# Add any paths that contain custom themes here, relative to this directory.
# html_theme_path = []
# The name for this set of Sphinx documents.
# "<project> v<release> documentation" by default.
#
# html_title = u'django-cas-server v5.0'
# A shorter title for the navigation bar. Default is the same as html_title.
#
# html_short_title = None
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
#
# html_logo = None
# The name of an image file (relative to this directory) to use as a favicon of
# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
#
# html_favicon = None
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# Add any extra paths that contain custom files (such as robots.txt or
# .htaccess) here, relative to this directory. These files are copied
# directly to the root of the documentation.
#
# html_extra_path = []
# If not None, a 'Last updated on:' timestamp is inserted at every page
# bottom, using the given strftime format.
# The empty string is equivalent to '%b %d, %Y'.
#
# html_last_updated_fmt = None
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
#
# html_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
#
# html_sidebars = {}
# Additional templates that should be rendered to pages, maps page names to
# template names.
#
# html_additional_pages = {}
# If false, no module index is generated.
#
# html_domain_indices = True
# If false, no index is generated.
#
# html_use_index = True
# If true, the index is split into individual pages for each letter.
#
# html_split_index = False
# If true, links to the reST sources are added to the pages.
#
# html_show_sourcelink = True
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
#
# html_show_sphinx = True
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
#
# html_show_copyright = True
# If true, an OpenSearch description file will be output, and all pages will
# contain a <link> tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served.
#
# html_use_opensearch = ''
# This is the file name suffix for HTML files (e.g. ".xhtml").
# html_file_suffix = None
# Language to be used for generating the HTML full-text search index.
# Sphinx supports the following languages:
# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja'
# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh'
#
# html_search_language = 'en'
# A dictionary with options for the search language support, empty by default.
# 'ja' uses this config value.
# 'zh' user can custom change `jieba` dictionary path.
#
# html_search_options = {'type': 'default'}
# The name of a javascript file (relative to the configuration directory) that
# implements a search results scorer. If empty, the default will be used.
#
# html_search_scorer = 'scorer.js'
# Output file base name for HTML help builder.
htmlhelp_basename = 'django-cas-serverdoc'
# -- Options for LaTeX output ---------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#
# 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#
# 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#
# 'preamble': '',
# Latex figure (float) alignment
#
# 'figure_align': 'htbp',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
(master_doc, 'django-cas-server.tex', u'django-cas-server Documentation',
u'Valentin Samir', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
# the title page.
#
# latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
#
# latex_use_parts = False
# If true, show page references after internal links.
#
# latex_show_pagerefs = False
# If true, show URL addresses after external links.
#
# latex_show_urls = False
# Documents to append as an appendix to all manuals.
#
# latex_appendices = []
# If false, no module index is generated.
#
# latex_domain_indices = True
# -- Options for manual page output ---------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
(master_doc, 'django-cas-server', u'django-cas-server Documentation',
[author], 1)
]
# If true, show URL addresses after external links.
#
# man_show_urls = False
# -- Options for Texinfo output -------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(master_doc, 'django-cas-server', u'django-cas-server Documentation',
author, 'django-cas-server', 'One line description of project.',
'Miscellaneous'),
]
# Documents to append as an appendix to all manuals.
#
# texinfo_appendices = []
# If false, no module index is generated.
#
# texinfo_domain_indices = True
# How to display URL addresses: 'footnote', 'no', or 'inline'.
#
# texinfo_show_urls = 'footnote'
# If true, do not generate a @detailmenu in the "Top" node's menu.
#
# texinfo_no_detailmenu = False
# Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {
"python": ('https://docs.python.org/', None),
"django": ('https://docs.djangoproject.com/en/1.9/', 'django.inv'),
}
autodoc_member_order = 'bysource'
locale_dirs = ['../test_venv/lib/python2.7/site-packages/django/conf/locale/']
def _download_django_inv():
import requests
with open(_download_django_inv.path, 'w') as f:
r = requests.get("https://docs.djangoproject.com/en/1.9/_objects")
f.write(r.content)
_download_django_inv.path = os.path.abspath(os.path.join(os.path.dirname(__file__), "django.inv"))
if not os.path.isfile(_download_django_inv.path):
_download_django_inv()

23
docs/index.rst Normal file
View file

@ -0,0 +1,23 @@
.. django-cas-server documentation master file, created by
sphinx-quickstart on Tue Jul 5 12:11:50 2016.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
django-cas-server documentation
===============================
Contents:
.. toctree::
:maxdepth: 3
README
package/cas_server
Indices and tables
==================
* :ref:`genindex`
.. * :ref:`modindex`
.. * :ref:`search`

281
docs/make.bat Normal file
View file

@ -0,0 +1,281 @@
@ECHO OFF
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set BUILDDIR=_build
set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% .
set I18NSPHINXOPTS=%SPHINXOPTS% .
if NOT "%PAPER%" == "" (
set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%
)
if "%1" == "" goto help
if "%1" == "help" (
:help
echo.Please use `make ^<target^>` where ^<target^> is one of
echo. html to make standalone HTML files
echo. dirhtml to make HTML files named index.html in directories
echo. singlehtml to make a single large HTML file
echo. pickle to make pickle files
echo. json to make JSON files
echo. htmlhelp to make HTML files and a HTML help project
echo. qthelp to make HTML files and a qthelp project
echo. devhelp to make HTML files and a Devhelp project
echo. epub to make an epub
echo. epub3 to make an epub3
echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter
echo. text to make text files
echo. man to make manual pages
echo. texinfo to make Texinfo files
echo. gettext to make PO message catalogs
echo. changes to make an overview over all changed/added/deprecated items
echo. xml to make Docutils-native XML files
echo. pseudoxml to make pseudoxml-XML files for display purposes
echo. linkcheck to check all external links for integrity
echo. doctest to run all doctests embedded in the documentation if enabled
echo. coverage to run coverage check of the documentation if enabled
echo. dummy to check syntax errors of document sources
goto end
)
if "%1" == "clean" (
for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
del /q /s %BUILDDIR%\*
goto end
)
REM Check if sphinx-build is available and fallback to Python version if any
%SPHINXBUILD% 1>NUL 2>NUL
if errorlevel 9009 goto sphinx_python
goto sphinx_ok
:sphinx_python
set SPHINXBUILD=python -m sphinx.__init__
%SPHINXBUILD% 2> nul
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)
:sphinx_ok
if "%1" == "html" (
%SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The HTML pages are in %BUILDDIR%/html.
goto end
)
if "%1" == "dirhtml" (
%SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
goto end
)
if "%1" == "singlehtml" (
%SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
goto end
)
if "%1" == "pickle" (
%SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
if errorlevel 1 exit /b 1
echo.
echo.Build finished; now you can process the pickle files.
goto end
)
if "%1" == "json" (
%SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
if errorlevel 1 exit /b 1
echo.
echo.Build finished; now you can process the JSON files.
goto end
)
if "%1" == "htmlhelp" (
%SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
if errorlevel 1 exit /b 1
echo.
echo.Build finished; now you can run HTML Help Workshop with the ^
.hhp project file in %BUILDDIR%/htmlhelp.
goto end
)
if "%1" == "qthelp" (
%SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
if errorlevel 1 exit /b 1
echo.
echo.Build finished; now you can run "qcollectiongenerator" with the ^
.qhcp project file in %BUILDDIR%/qthelp, like this:
echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-cas-server.qhcp
echo.To view the help file:
echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-cas-server.ghc
goto end
)
if "%1" == "devhelp" (
%SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
if errorlevel 1 exit /b 1
echo.
echo.Build finished.
goto end
)
if "%1" == "epub" (
%SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The epub file is in %BUILDDIR%/epub.
goto end
)
if "%1" == "epub3" (
%SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The epub3 file is in %BUILDDIR%/epub3.
goto end
)
if "%1" == "latex" (
%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
if errorlevel 1 exit /b 1
echo.
echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
goto end
)
if "%1" == "latexpdf" (
%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
cd %BUILDDIR%/latex
make all-pdf
cd %~dp0
echo.
echo.Build finished; the PDF files are in %BUILDDIR%/latex.
goto end
)
if "%1" == "latexpdfja" (
%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
cd %BUILDDIR%/latex
make all-pdf-ja
cd %~dp0
echo.
echo.Build finished; the PDF files are in %BUILDDIR%/latex.
goto end
)
if "%1" == "text" (
%SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The text files are in %BUILDDIR%/text.
goto end
)
if "%1" == "man" (
%SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The manual pages are in %BUILDDIR%/man.
goto end
)
if "%1" == "texinfo" (
%SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
goto end
)
if "%1" == "gettext" (
%SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
goto end
)
if "%1" == "changes" (
%SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
if errorlevel 1 exit /b 1
echo.
echo.The overview file is in %BUILDDIR%/changes.
goto end
)
if "%1" == "linkcheck" (
%SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
if errorlevel 1 exit /b 1
echo.
echo.Link check complete; look for any errors in the above output ^
or in %BUILDDIR%/linkcheck/output.txt.
goto end
)
if "%1" == "doctest" (
%SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
if errorlevel 1 exit /b 1
echo.
echo.Testing of doctests in the sources finished, look at the ^
results in %BUILDDIR%/doctest/output.txt.
goto end
)
if "%1" == "coverage" (
%SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage
if errorlevel 1 exit /b 1
echo.
echo.Testing of coverage in the sources finished, look at the ^
results in %BUILDDIR%/coverage/python.txt.
goto end
)
if "%1" == "xml" (
%SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The XML files are in %BUILDDIR%/xml.
goto end
)
if "%1" == "pseudoxml" (
%SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml.
goto end
)
if "%1" == "dummy" (
%SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy
if errorlevel 1 exit /b 1
echo.
echo.Build finished. Dummy builder generates no files.
goto end
)
:end

View file

@ -0,0 +1,7 @@
cas_server.admin module
=======================
.. automodule:: cas_server.admin
:members:
:undoc-members:

View file

@ -0,0 +1,6 @@
cas_server.apps module
======================
.. automodule:: cas_server.apps
:members:
:undoc-members:

View file

@ -0,0 +1,7 @@
cas_server.auth module
======================
.. automodule:: cas_server.auth
:members:
:undoc-members:
:show-inheritance:

View file

@ -0,0 +1,7 @@
cas_server.cas module
=====================
.. automodule:: cas_server.cas
:members:
:undoc-members:
:show-inheritance:

View file

@ -0,0 +1,7 @@
cas_server.default_settings module
==================================
.. automodule:: cas_server.default_settings
:members:
:undoc-members:
:show-inheritance:

View file

@ -0,0 +1,7 @@
cas_server.federate module
==========================
.. automodule:: cas_server.federate
:members:
:undoc-members:
:show-inheritance:

View file

@ -0,0 +1,5 @@
cas_server.forms module
=======================
.. automodule:: cas_server.forms
:members:

View file

@ -0,0 +1,6 @@
cas_server.models module
========================
.. automodule:: cas_server.models
:members:
:undoc-members:

View file

@ -0,0 +1,27 @@
cas_server package
==================
Submodules
----------
.. toctree::
cas_server.admin
cas_server.apps
cas_server.auth
cas_server.cas
cas_server.default_settings
cas_server.federate
cas_server.forms
cas_server.models
cas_server.urls
cas_server.utils
cas_server.views
Module contents
---------------
.. automodule:: cas_server
:members:
:undoc-members:
:show-inheritance:

View file

@ -0,0 +1,7 @@
cas_server.utils module
=======================
.. automodule:: cas_server.utils
:members:
:undoc-members:
:show-inheritance:

View file

@ -0,0 +1,7 @@
cas_server.views module
=======================
.. automodule:: cas_server.views
:members:
:undoc-members:
:show-inheritance:

7
docs/package/modules.rst Normal file
View file

@ -0,0 +1,7 @@
cas_server
==========
.. toctree::
:maxdepth: 4
cas_server

View file

@ -6,7 +6,5 @@ pytest-pythonpath>=0.3
pytest-cov>=2.2.1 pytest-cov>=2.2.1
requests>=2.4 requests>=2.4
requests_futures>=0.9.5 requests_futures>=0.9.5
django-picklefield>=0.3.1
django-bootstrap3>=5.4
lxml>=3.4 lxml>=3.4
six>=1 six>=1

View file

@ -2,7 +2,5 @@ Django >= 1.8,<1.10
setuptools>=5.5 setuptools>=5.5
requests>=2.4 requests>=2.4
requests_futures>=0.9.5 requests_futures>=0.9.5
django-picklefield>=0.3.1
django-bootstrap3>=5.4
lxml>=3.4 lxml>=3.4
six>=1 six>=1

View file

@ -1,2 +1,13 @@
[metadata] [metadata]
description-file = README.rst description-file = README.rst
[egg_info]
tag_build =
tag_date = 0
tag_svn_revision = 0
[aliases]
test=pytest
[bdist_wheel]
universal = 1

131
setup.py
View file

@ -2,76 +2,69 @@ import os
import pkg_resources import pkg_resources
from setuptools import setup from setuptools import setup
VERSION = '0.6.0'
with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as readme: with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as readme:
README = readme.read() README = readme.read()
# allow setup.py to be run from any path if __name__ == '__main__':
os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) # allow setup.py to be run from any path
os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir)))
setup(
# if we have Django 1.8 available, use last version of django-boostrap3 name='django-cas-server',
try: version=VERSION,
pkg_resources.require('Django >= 1.8') packages=[
django_bootstrap3 = 'django-bootstrap3 >= 5.4' 'cas_server', 'cas_server.migrations',
django = 'Django >= 1.8,<1.10' 'cas_server.management', 'cas_server.management.commands',
except pkg_resources.VersionConflict: 'cas_server.tests'
# Else if we have django 1.7, we need django-boostrap3 < 7.0.0 ],
try: include_package_data=True,
pkg_resources.require('Django >= 1.7') license='GPLv3',
django_bootstrap3 = 'django-bootstrap3 >= 5.4,<7.0.0' description=(
django = 'Django >= 1.7,<1.8' 'A Django Central Authentication Service server '
except (pkg_resources.VersionConflict, pkg_resources.DistributionNotFound): 'implementing the CAS Protocol 3.0 Specification'
# Else we need to install Django, assume version will be >= 1.8 ),
django_bootstrap3 = 'django-bootstrap3 >= 5.4' long_description=README,
django = 'Django >= 1.8,<1.10' author='Valentin Samir',
# No version of django installed, assume version will be >= 1.8 author_email='valentin.samir@crans.org',
except pkg_resources.DistributionNotFound: classifiers=[
django_bootstrap3 = 'django-bootstrap3 >= 5.4' 'Environment :: Web Environment',
django = 'Django >= 1.8,<1.10' 'evelopment Status :: 5 - Production/Stable',
'Framework :: Django',
setup( 'Framework :: Django :: 1.7',
name='django-cas-server', 'Framework :: Django :: 1.8',
version='0.6.0', 'Framework :: Django :: 1.9',
packages=[ 'Intended Audience :: Developers',
'cas_server', 'cas_server.migrations', 'Intended Audience :: System Administrators',
'cas_server.management', 'cas_server.management.commands', 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
'cas_server.tests' 'Operating System :: OS Independent',
], 'Programming Language :: Python',
include_package_data=True, 'Programming Language :: Python :: 2',
license='GPLv3', 'Programming Language :: Python :: 2.7',
description=( 'Programming Language :: Python :: 3',
'A Django Central Authentication Service server ' 'Programming Language :: Python :: 3.4',
'implementing the CAS Protocol 3.0 Specification' 'Programming Language :: Python :: 3.5',
), 'Topic :: Software Development :: Libraries :: Python Modules',
long_description=README, 'Topic :: Internet :: WWW/HTTP',
author='Valentin Samir', 'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
author_email='valentin.samir@crans.org', 'Topic :: System :: Systems Administration :: Authentication/Directory'
classifiers=[ ],
'Environment :: Web Environment', package_data={
'Framework :: Django', 'cas_server': [
'Intended Audience :: Developers', 'templates/cas_server/*',
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 'static/cas_server/*',
'Operating System :: OS Independent', 'locale/*/LC_MESSAGES/*',
'Programming Language :: Python', ]
'Programming Language :: Python :: 2', },
'Programming Language :: Python :: 3', keywords=['django', 'cas', 'cas3', 'server', 'sso', 'single sign-on', 'authentication', 'auth'],
'Topic :: Internet :: WWW/HTTP', install_requires=[
'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 'Django >= 1.7,<1.10', 'requests >= 2.4', 'requests_futures >= 0.9.5',
], 'lxml >= 3.4', 'six >= 1'
package_data={ ],
'cas_server': [ url="https://github.com/nitmir/django-cas-server",
'templates/cas_server/*', download_url="https://github.com/nitmir/django-cas-server/releases",
'static/cas_server/*', zip_safe=False,
'locale/*/LC_MESSAGES/*', setup_requires=['pytest-runner'],
] tests_require=['pytest', 'pytest-django', 'pytest-pythonpath'],
}, )
keywords=['django', 'cas', 'cas3', 'server', 'sso', 'single sign-on', 'authentication', 'auth'],
install_requires=[
django, 'requests >= 2.4', 'requests_futures >= 0.9.5',
'django-picklefield >= 0.3.1', django_bootstrap3, 'lxml >= 3.4',
'six >= 1'
],
url="https://github.com/nitmir/django-cas-server",
download_url="https://github.com/nitmir/django-cas-server/releases",
zip_safe=False
)

15
tox.ini
View file

@ -8,6 +8,8 @@ envlist=
py34-django17, py34-django17,
py34-django18, py34-django18,
py34-django19, py34-django19,
py35-django18,
py35-django19,
[flake8] [flake8]
max-line-length=100 max-line-length=100
@ -69,6 +71,19 @@ deps =
Django>=1.9,<1.10 Django>=1.9,<1.10
{[base]deps} {[base]deps}
[testenv:py35-django18]
basepython=python3.5
deps =
Django>=1.8,<1.9
{[base]deps}
[testenv:py35-django19]
basepython=python3.5
deps =
Django>=1.9,<1.10
{[base]deps}
[testenv:flake8] [testenv:flake8]
basepython=python basepython=python
deps=flake8 deps=flake8