From 6dc52e0ca5e5dc21eef6b923e93dd1818243e483 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Wed, 6 Jul 2016 19:43:09 +0200 Subject: [PATCH 01/26] Update some help_text dans docstrings --- .../migrations/0006_auto_20160706_1727.py | 40 +++++++++++++++++++ cas_server/models.py | 40 ++++++++++++++----- 2 files changed, 69 insertions(+), 11 deletions(-) create mode 100644 cas_server/migrations/0006_auto_20160706_1727.py diff --git a/cas_server/migrations/0006_auto_20160706_1727.py b/cas_server/migrations/0006_auto_20160706_1727.py new file mode 100644 index 0000000..0a30642 --- /dev/null +++ b/cas_server/migrations/0006_auto_20160706_1727.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-07-06 17:27 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cas_server', '0005_auto_20160616_1018'), + ] + + operations = [ + migrations.AlterField( + model_name='federatediendityprovider', + name='cas_protocol_version', + field=models.CharField(choices=[(b'1', b'CAS 1.0'), (b'2', b'CAS 2.0'), (b'3', b'CAS 3.0'), (b'CAS_2_SAML_1_0', b'SAML 1.1')], default=b'3', help_text='Version of the CAS protocol to use when sending requests the the backend CAS.', max_length=30, verbose_name='CAS protocol version'), + ), + migrations.AlterField( + model_name='federatediendityprovider', + name='display', + field=models.BooleanField(default=True, help_text='Display the provider on the login page.', verbose_name='display'), + ), + migrations.AlterField( + model_name='federatediendityprovider', + name='pos', + field=models.IntegerField(default=100, help_text='Position of the identity provider on the login page. Identity provider are sorted using the (position, verbose name, suffix) attributes.', verbose_name='position'), + ), + migrations.AlterField( + model_name='federatediendityprovider', + name='suffix', + field=models.CharField(help_text='Suffix append to backend CAS returner username: ``returned_username`` @ ``suffix``.', max_length=30, unique=True, verbose_name='suffix'), + ), + migrations.AlterField( + model_name='federatediendityprovider', + name='verbose_name', + field=models.CharField(help_text='Name for this identity provider displayed on the login page.', max_length=255, verbose_name='verbose name'), + ), + ] diff --git a/cas_server/models.py b/cas_server/models.py index 2314c4f..d741bf2 100644 --- a/cas_server/models.py +++ b/cas_server/models.py @@ -37,15 +37,20 @@ logger = logging.getLogger(__name__) @python_2_unicode_compatible class FederatedIendityProvider(models.Model): - """An identity provider for the federated mode""" + """ + An identity provider for the federated mode + """ class Meta: - verbose_name = _("identity provider") - verbose_name_plural = _("identity providers") + verbose_name = _(u"identity provider") + verbose_name_plural = _(u"identity providers") suffix = models.CharField( max_length=30, unique=True, verbose_name=_(u"suffix"), - help_text=_("Suffix append to backend CAS returner username: `returned_username`@`suffix`") + help_text=_( + u"Suffix append to backend CAS returner " + u"username: ``returned_username`` @ ``suffix``." + ) ) server_url = models.CharField(max_length=255, verbose_name=_(u"server url")) cas_protocol_version = models.CharField( @@ -57,28 +62,31 @@ class FederatedIendityProvider(models.Model): ("CAS_2_SAML_1_0", "SAML 1.1") ], verbose_name=_(u"CAS protocol version"), - help_text=_("Version of the CAS protocol to use when sending requests the the backend CAS"), + help_text=_( + u"Version of the CAS protocol to use when sending requests the the backend CAS." + ), default="3" ) verbose_name = models.CharField( max_length=255, verbose_name=_(u"verbose name"), - help_text=_("Name for this identity provider displayed on the login page") + help_text=_(u"Name for this identity provider displayed on the login page.") ) pos = models.IntegerField( default=100, verbose_name=_(u"position"), help_text=_( ( + u"Position of the identity provider on the login page. " u"Identity provider are sorted using the " - u"(position, verbose name, suffix) attributes" + u"(position, verbose name, suffix) attributes." ) ) ) display = models.BooleanField( default=True, verbose_name=_(u"display"), - help_text=_("Display the provider on the login page") + help_text=_("Display the provider on the login page.") ) def __str__(self): @@ -86,7 +94,12 @@ class FederatedIendityProvider(models.Model): @staticmethod def build_username_from_suffix(username, suffix): - """Transform backend username into federated username using `suffix`""" + """ + Transform backend username into federated username using ``suffix`` + + :param unicode username: A CAS backend returned username + :param unicode suffix: A suffix identifying the CAS backend + """ return u'%s@%s' % (username, suffix) def build_username(self, username): @@ -238,7 +251,7 @@ class User(models.Model): """ Generate a ticket using `ticket_class` for the service `service` matching `service_pattern` and asking or not for - authentication renewal with `renew + authentication renewal with `renew` """ attributs = dict( (a.name, a.replace if a.replace else a.name) for a in service_pattern.attributs.all() @@ -376,7 +389,12 @@ class ServicePattern(models.Model): return u"%s: %s" % (self.pos, self.pattern) def check_user(self, user): - """Check if `user` if allowed to use theses services""" + """ + Check if ``user`` if allowed to use theses services. If ``user`` is not allowed, + raises one of :class:`BadFilter`, :class:`UserFieldNotDefined`, :class:`BadUsername` + + :param user: a :class:`User` object + """ if self.restrict_users and not self.usernames.filter(value=user.username): logger.warning("Username %s not allowed on service %s" % (user.username, self.name)) raise BadUsername() From 91dbe6bafb140a0e41de01ed6efc4358f671e8cb Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Wed, 6 Jul 2016 19:43:56 +0200 Subject: [PATCH 02/26] Add sphinx docs --- .gitignore | 1 + Makefile | 17 +- docs/Makefile | 225 ++++++++++++ docs/README.rst | 1 + docs/conf.py | 357 +++++++++++++++++++ docs/coverage.rst | 2 + docs/django_sphinx.py | 41 +++ docs/index.rst | 24 ++ docs/make.bat | 281 +++++++++++++++ docs/package/cas_server.admin.rst | 7 + docs/package/cas_server.apps.rst | 7 + docs/package/cas_server.auth.rst | 7 + docs/package/cas_server.cas.rst | 7 + docs/package/cas_server.default_settings.rst | 7 + docs/package/cas_server.federate.rst | 7 + docs/package/cas_server.forms.rst | 7 + docs/package/cas_server.models.rst | 7 + docs/package/cas_server.rst | 27 ++ docs/package/cas_server.urls.rst | 7 + docs/package/cas_server.utils.rst | 7 + docs/package/cas_server.views.rst | 7 + docs/package/modules.rst | 7 + 22 files changed, 1058 insertions(+), 2 deletions(-) create mode 100644 docs/Makefile create mode 100644 docs/README.rst create mode 100644 docs/conf.py create mode 100644 docs/coverage.rst create mode 100644 docs/django_sphinx.py create mode 100644 docs/index.rst create mode 100644 docs/make.bat create mode 100644 docs/package/cas_server.admin.rst create mode 100644 docs/package/cas_server.apps.rst create mode 100644 docs/package/cas_server.auth.rst create mode 100644 docs/package/cas_server.cas.rst create mode 100644 docs/package/cas_server.default_settings.rst create mode 100644 docs/package/cas_server.federate.rst create mode 100644 docs/package/cas_server.forms.rst create mode 100644 docs/package/cas_server.models.rst create mode 100644 docs/package/cas_server.rst create mode 100644 docs/package/cas_server.urls.rst create mode 100644 docs/package/cas_server.utils.rst create mode 100644 docs/package/cas_server.views.rst create mode 100644 docs/package/modules.rst diff --git a/.gitignore b/.gitignore index 273399d..b7a007e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ dist/ db.sqlite3 manage.py coverage.xml +docs/_build/ .tox test_venv diff --git a/Makefile b/Makefile index 7a44d9b..b115673 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build dist +.PHONY: build dist docs VERSION=`python setup.py -V` build: @@ -24,10 +24,13 @@ clean_coverage: rm -rf coverage.xml .coverage htmlcov clean_tild_backup: find ./ -name '*~' -delete +clean_docs: + rm -rf docs/_build/ + rm -rf docs/package/ 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 dist: python setup.py sdist @@ -60,3 +63,13 @@ run_tests: test_venv python setup.py check --restructuredtext --stric test_venv/bin/py.test --cov=cas_server --cov-report html rm htmlcov/coverage_html.js # I am really pissed off by those keybord shortcuts + +test_venv/bin/sphinx-build: test_venv + test_venv/bin/pip install Sphinx sphinx_rtd_theme + + +docs/package: test_venv/bin/sphinx-build + test_venv/bin/sphinx-apidoc -f -e cas_server -o docs/package/ cas_server/migrations/ cas_server/management/ cas_server/tests/ #cas_server/cas.py + +docs: docs/package test_venv/bin/sphinx-build + cd docs; export PATH=$(realpath test_venv/bin/):$$PATH; make coverage html diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..8df0199 --- /dev/null +++ b/docs/Makefile @@ -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 ' where 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." diff --git a/docs/README.rst b/docs/README.rst new file mode 100644 index 0000000..01e416f --- /dev/null +++ b/docs/README.rst @@ -0,0 +1 @@ +.. include:: ../README.rst diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..2595b6d --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,357 @@ +# -*- 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('..')) + +SETUP = os.path.abspath('../setup.py') + +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 = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.doctest', + 'sphinx.ext.intersphinx', + 'sphinx.ext.coverage', + 'sphinx.ext.viewcode', + 'sphinx.ext.githubpages', +] + +# 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 = os.popen("python %s -V" % SETUP).read().strip().decode("ascii") +# 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. +# " v 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 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 = {'https://docs.python.org/': None} + +autodoc_member_order = 'bysource' + +def setup(app): + from django_sphinx import process_docstring + # Register the docstring processor with sphinx + app.connect('autodoc-process-docstring', process_docstring) diff --git a/docs/coverage.rst b/docs/coverage.rst new file mode 100644 index 0000000..12ab2c7 --- /dev/null +++ b/docs/coverage.rst @@ -0,0 +1,2 @@ +.. include:: _build/coverage/python.txt + diff --git a/docs/django_sphinx.py b/docs/django_sphinx.py new file mode 100644 index 0000000..df5613d --- /dev/null +++ b/docs/django_sphinx.py @@ -0,0 +1,41 @@ +import inspect +from django.utils.html import strip_tags +from django.utils.encoding import force_unicode + +def process_docstring(app, what, name, obj, options, lines): + # This causes import errors if left outside the function + from django.db import models + + # Only look at objects that inherit from Django's base model class + if inspect.isclass(obj) and issubclass(obj, models.Model): + # Grab the field list from the meta class + fields = obj._meta.fields + + for field in fields: + # Decode and strip any html out of the field's help text + help_text = strip_tags(force_unicode(field.help_text)) + + # Decode and capitalize the verbose name, for use if there isn't + # any help text + verbose_name = force_unicode(field.verbose_name).capitalize() + + if help_text: + # Add the model field to the end of the docstring as a param + # using the help text as the description + lines.append(u':param %s: %s' % (field.attname, help_text)) + else: + # Add the model field to the end of the docstring as a param + # using the verbose name as the description + lines.append(u':param %s: %s' % (field.attname, verbose_name)) + + # Add the field's type to the docstring + if isinstance(field, models.ForeignKey): + to = field.rel.to + lines.append(u':type %s: %s to :class:`~%s.%s`' % (field.attname, type(field).__name__, to.__module__, to.__name__)) + + else: + lines.append(u':type %s: %s' % (field.attname, type(field).__name__)) + + # Return the extended docstring + return lines + diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..c981028 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,24 @@ +.. 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` +* :doc:`coverage` +.. * :ref:`modindex` +.. * :ref:`search` + diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..331ed2e --- /dev/null +++ b/docs/make.bat @@ -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 ^` where ^ 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 diff --git a/docs/package/cas_server.admin.rst b/docs/package/cas_server.admin.rst new file mode 100644 index 0000000..439fcea --- /dev/null +++ b/docs/package/cas_server.admin.rst @@ -0,0 +1,7 @@ +cas_server.admin module +======================= + +.. automodule:: cas_server.admin + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/package/cas_server.apps.rst b/docs/package/cas_server.apps.rst new file mode 100644 index 0000000..745ec67 --- /dev/null +++ b/docs/package/cas_server.apps.rst @@ -0,0 +1,7 @@ +cas_server.apps module +====================== + +.. automodule:: cas_server.apps + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/package/cas_server.auth.rst b/docs/package/cas_server.auth.rst new file mode 100644 index 0000000..f475de5 --- /dev/null +++ b/docs/package/cas_server.auth.rst @@ -0,0 +1,7 @@ +cas_server.auth module +====================== + +.. automodule:: cas_server.auth + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/package/cas_server.cas.rst b/docs/package/cas_server.cas.rst new file mode 100644 index 0000000..c5d51c1 --- /dev/null +++ b/docs/package/cas_server.cas.rst @@ -0,0 +1,7 @@ +cas_server.cas module +===================== + +.. automodule:: cas_server.cas + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/package/cas_server.default_settings.rst b/docs/package/cas_server.default_settings.rst new file mode 100644 index 0000000..ef5fad9 --- /dev/null +++ b/docs/package/cas_server.default_settings.rst @@ -0,0 +1,7 @@ +cas_server.default_settings module +================================== + +.. automodule:: cas_server.default_settings + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/package/cas_server.federate.rst b/docs/package/cas_server.federate.rst new file mode 100644 index 0000000..d8969b5 --- /dev/null +++ b/docs/package/cas_server.federate.rst @@ -0,0 +1,7 @@ +cas_server.federate module +========================== + +.. automodule:: cas_server.federate + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/package/cas_server.forms.rst b/docs/package/cas_server.forms.rst new file mode 100644 index 0000000..457ab51 --- /dev/null +++ b/docs/package/cas_server.forms.rst @@ -0,0 +1,7 @@ +cas_server.forms module +======================= + +.. automodule:: cas_server.forms + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/package/cas_server.models.rst b/docs/package/cas_server.models.rst new file mode 100644 index 0000000..8d11266 --- /dev/null +++ b/docs/package/cas_server.models.rst @@ -0,0 +1,7 @@ +cas_server.models module +======================== + +.. automodule:: cas_server.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/package/cas_server.rst b/docs/package/cas_server.rst new file mode 100644 index 0000000..62c00c4 --- /dev/null +++ b/docs/package/cas_server.rst @@ -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: diff --git a/docs/package/cas_server.urls.rst b/docs/package/cas_server.urls.rst new file mode 100644 index 0000000..5127b5a --- /dev/null +++ b/docs/package/cas_server.urls.rst @@ -0,0 +1,7 @@ +cas_server.urls module +====================== + +.. automodule:: cas_server.urls + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/package/cas_server.utils.rst b/docs/package/cas_server.utils.rst new file mode 100644 index 0000000..4f8d635 --- /dev/null +++ b/docs/package/cas_server.utils.rst @@ -0,0 +1,7 @@ +cas_server.utils module +======================= + +.. automodule:: cas_server.utils + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/package/cas_server.views.rst b/docs/package/cas_server.views.rst new file mode 100644 index 0000000..4f073d3 --- /dev/null +++ b/docs/package/cas_server.views.rst @@ -0,0 +1,7 @@ +cas_server.views module +======================= + +.. automodule:: cas_server.views + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/package/modules.rst b/docs/package/modules.rst new file mode 100644 index 0000000..d4bdb3c --- /dev/null +++ b/docs/package/modules.rst @@ -0,0 +1,7 @@ +cas_server +========== + +.. toctree:: + :maxdepth: 4 + + cas_server From 4d0d60155e8f1468be1111d0d4868680b0107a9a Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Wed, 6 Jul 2016 19:52:48 +0200 Subject: [PATCH 03/26] Remove sphinx.ext.githubpages extension for docs --- docs/conf.py | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 2595b6d..f8a40a0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -42,7 +42,6 @@ extensions = [ 'sphinx.ext.intersphinx', 'sphinx.ext.coverage', 'sphinx.ext.viewcode', - 'sphinx.ext.githubpages', ] # Add any paths that contain templates here, relative to this directory. From b1b7562e55abb3fdf4482ba69ae6ee597274a814 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Thu, 7 Jul 2016 00:09:49 +0200 Subject: [PATCH 04/26] DO not display sphinx coverage for now As it seems to not work --- docs/coverage.rst | 2 -- docs/index.rst | 1 - 2 files changed, 3 deletions(-) delete mode 100644 docs/coverage.rst diff --git a/docs/coverage.rst b/docs/coverage.rst deleted file mode 100644 index 12ab2c7..0000000 --- a/docs/coverage.rst +++ /dev/null @@ -1,2 +0,0 @@ -.. include:: _build/coverage/python.txt - diff --git a/docs/index.rst b/docs/index.rst index c981028..7062ab0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -18,7 +18,6 @@ Indices and tables ================== * :ref:`genindex` -* :doc:`coverage` .. * :ref:`modindex` .. * :ref:`search` From 28dd67cb32f5d03db0e42cdc5874a492da808715 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sun, 10 Jul 2016 11:48:43 +0200 Subject: [PATCH 05/26] Add the possibility to run tests with "setup.py test" --- .gitignore | 1 + Makefile | 4 +++- setup.cfg | 9 +++++++++ setup.py | 4 +++- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index b7a007e..eae66d3 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ test_venv htmlcov/ tox_logs/ .cache/ +.eggs/ diff --git a/Makefile b/Makefile index b115673..a9b136f 100644 --- a/Makefile +++ b/Makefile @@ -27,10 +27,12 @@ clean_tild_backup: clean_docs: rm -rf docs/_build/ rm -rf docs/package/ +clean_eggs: + rm -rf .eggs/ clean: clean_pyc clean_build clean_coverage clean_tild_backup -clean_all: clean clean_tox clean_test_venv clean_docs +clean_all: clean clean_tox clean_test_venv clean_docs clean_eggs dist: python setup.py sdist diff --git a/setup.cfg b/setup.cfg index 5aef279..f1602a2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,11 @@ [metadata] description-file = README.rst + +[egg_info] +tag_build = +tag_date = 0 +tag_svn_revision = 0 + +[aliases] +test=pytest + diff --git a/setup.py b/setup.py index a8af44c..85a8838 100644 --- a/setup.py +++ b/setup.py @@ -73,5 +73,7 @@ setup( ], url="https://github.com/nitmir/django-cas-server", download_url="https://github.com/nitmir/django-cas-server/releases", - zip_safe=False + zip_safe=False, + setup_requires=['pytest-runner'], + tests_require=['pytest', 'pytest-django', 'pytest-pythonpath'], ) From cec0cadb7a26a7be1b6df05730198c3ab25565de Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Wed, 20 Jul 2016 18:28:23 +0200 Subject: [PATCH 06/26] Add some docs using sphinx autodoc --- .gitignore | 1 + Makefile | 5 +- cas_server/__init__.py | 1 + cas_server/admin.py | 143 +++++++++---- cas_server/apps.py | 8 +- cas_server/auth.py | 164 ++++++++++++--- cas_server/federate.py | 58 +++++- cas_server/forms.py | 85 +++++++- cas_server/models.py | 316 ++++++++++++++++++++++++---- cas_server/utils.py | 283 ++++++++++++++++++++----- cas_server/views.py | 5 +- docs/Makefile | 1 + docs/_ext/djangodocs.py | 321 +++++++++++++++++++++++++++++ docs/conf.py | 12 +- docs/django_sphinx.py | 41 ---- docs/package/cas_server.admin.rst | 2 +- docs/package/cas_server.apps.rst | 1 - docs/package/cas_server.forms.rst | 2 - docs/package/cas_server.models.rst | 1 - docs/package/cas_server.urls.rst | 7 - 20 files changed, 1223 insertions(+), 234 deletions(-) create mode 100644 docs/_ext/djangodocs.py delete mode 100644 docs/django_sphinx.py delete mode 100644 docs/package/cas_server.urls.rst diff --git a/.gitignore b/.gitignore index eae66d3..f2b02b7 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ db.sqlite3 manage.py coverage.xml docs/_build/ +docs/django.inv .tox test_venv diff --git a/Makefile b/Makefile index a9b136f..a83a1e4 100644 --- a/Makefile +++ b/Makefile @@ -25,8 +25,7 @@ clean_coverage: clean_tild_backup: find ./ -name '*~' -delete clean_docs: - rm -rf docs/_build/ - rm -rf docs/package/ + rm -rf docs/_build/ docs/django.inv clean_eggs: rm -rf .eggs/ @@ -74,4 +73,4 @@ docs/package: test_venv/bin/sphinx-build test_venv/bin/sphinx-apidoc -f -e cas_server -o docs/package/ cas_server/migrations/ cas_server/management/ cas_server/tests/ #cas_server/cas.py docs: docs/package test_venv/bin/sphinx-build - cd docs; export PATH=$(realpath test_venv/bin/):$$PATH; make coverage html + bash -c "source test_venv/bin/activate; cd docs; make html" diff --git a/cas_server/__init__.py b/cas_server/__init__.py index 29f5de6..085927b 100644 --- a/cas_server/__init__.py +++ b/cas_server/__init__.py @@ -9,4 +9,5 @@ # # (c) 2015-2016 Valentin Samir """A django CAS server application""" +#: path the the application configuration class default_app_config = 'cas_server.apps.CasAppConfig' diff --git a/cas_server/admin.py b/cas_server/admin.py index 848b481..6e5c318 100644 --- a/cas_server/admin.py +++ b/cas_server/admin.py @@ -15,86 +15,155 @@ from .models import Username, ReplaceAttributName, ReplaceAttributValue, FilterA from .models import FederatedIendityProvider from .forms import TicketForm -TICKETS_READONLY_FIELDS = ('validate', 'service', 'service_pattern', - 'creation', 'renew', 'single_log_out', 'value') -TICKETS_FIELDS = ('validate', 'service', 'service_pattern', - 'creation', 'renew', 'single_log_out') + +class BaseInlines(admin.TabularInline): + """ + Bases: :class:`django.contrib.admin.TabularInline` + + Base class for inlines in the admin interface. + """ + #: This controls the number of extra forms the formset will display in addition to + #: the initial forms. + extra = 0 -class ServiceTicketInline(admin.TabularInline): - """`ServiceTicket` in admin interface""" +class UserAdminInlines(BaseInlines): + """ + Bases: :class:`BaseInlines` + + Base class for inlines in :class:`UserAdmin` interface + """ + #: The form :class:`TicketForm` used to display tickets. + form = TicketForm + #: Fields to display on a object that are read only (not editable). + readonly_fields = ( + 'validate', 'service', 'service_pattern', + 'creation', 'renew', 'single_log_out', 'value' + ) + #: Fields to display on a object. + fields = ( + 'validate', 'service', 'service_pattern', + 'creation', 'renew', 'single_log_out' + ) + + +class ServiceTicketInline(UserAdminInlines): + """ + Bases: :class:`UserAdminInlines` + + :class:`ServiceTicket` in admin interface + """ + #: The model which the inline is using. model = ServiceTicket - extra = 0 - form = TicketForm - readonly_fields = TICKETS_READONLY_FIELDS - fields = TICKETS_FIELDS -class ProxyTicketInline(admin.TabularInline): - """`ProxyTicket` in admin interface""" +class ProxyTicketInline(UserAdminInlines): + """ + Bases: :class:`UserAdminInlines` + + :class:`ProxyTicket` in admin interface + """ + #: The model which the inline is using. model = ProxyTicket - extra = 0 - form = TicketForm - readonly_fields = TICKETS_READONLY_FIELDS - fields = TICKETS_FIELDS -class ProxyGrantingInline(admin.TabularInline): - """`ProxyGrantingTicket` in admin interface""" +class ProxyGrantingInline(UserAdminInlines): + """ + Bases: :class:`UserAdminInlines` + + :class:`ProxyGrantingTicket` in admin interface + """ + #: The model which the inline is using. model = ProxyGrantingTicket - extra = 0 - form = TicketForm - readonly_fields = TICKETS_READONLY_FIELDS - fields = TICKETS_FIELDS[1:] class UserAdmin(admin.ModelAdmin): - """`User` in admin interface""" + """ + Bases: :class:`django.contrib.admin.ModelAdmin` + + :class:`User` in admin interface + """ + #: See :class:`ServiceTicketInline`, :class:`ProxyTicketInline`, :class:`ProxyGrantingInline` + #: objects below the :class:`UserAdmin` fields. inlines = (ServiceTicketInline, ProxyTicketInline, ProxyGrantingInline) + #: Fields to display on a object that are read only (not editable). readonly_fields = ('username', 'date', "session_key") + #: Fields to display on a object. fields = ('username', 'date', "session_key") + #: Fields to display on the list of class:`UserAdmin` objects. list_display = ('username', 'date', "session_key") -class UsernamesInline(admin.TabularInline): - """`Username` in admin interface""" +class UsernamesInline(BaseInlines): + """ + Bases: :class:`BaseInlines` + + :class:`Username` in admin interface + """ + #: The model which the inline is using. model = Username - extra = 0 -class ReplaceAttributNameInline(admin.TabularInline): - """`ReplaceAttributName` in admin interface""" +class ReplaceAttributNameInline(BaseInlines): + """ + Bases: :class:`BaseInlines` + + :class:`ReplaceAttributName` in admin interface + """ + #: The model which the inline is using. model = ReplaceAttributName - extra = 0 -class ReplaceAttributValueInline(admin.TabularInline): - """`ReplaceAttributValue` in admin interface""" +class ReplaceAttributValueInline(BaseInlines): + """ + Bases: :class:`BaseInlines` + + :class:`ReplaceAttributValue` in admin interface + """ + #: The model which the inline is using. model = ReplaceAttributValue - extra = 0 -class FilterAttributValueInline(admin.TabularInline): - """`FilterAttributValue` in admin interface""" +class FilterAttributValueInline(BaseInlines): + """ + Bases: :class:`BaseInlines` + + :class:`FilterAttributValue` in admin interface + """ + #: The model which the inline is using. model = FilterAttributValue - extra = 0 class ServicePatternAdmin(admin.ModelAdmin): - """`ServicePattern` in admin interface""" + """ + Bases: :class:`django.contrib.admin.ModelAdmin` + + :class:`ServicePattern` in admin interface + """ + #: See :class:`UsernamesInline`, :class:`ReplaceAttributNameInline`, + #: :class:`ReplaceAttributValueInline`, :class:`FilterAttributValueInline` objects below + #: the :class:`ServicePatternAdmin` fields. inlines = ( UsernamesInline, ReplaceAttributNameInline, ReplaceAttributValueInline, FilterAttributValueInline ) + #: Fields to display on the list of class:`ServicePatternAdmin` objects. list_display = ('pos', 'name', 'pattern', 'proxy', 'single_log_out', 'proxy_callback', 'restrict_users') class FederatedIendityProviderAdmin(admin.ModelAdmin): - """`FederatedIendityProvider` in admin interface""" + """ + Bases: :class:`django.contrib.admin.ModelAdmin` + + :class:`FederatedIendityProvider` in admin + interface + """ + #: Fields to display on a object. fields = ('pos', 'suffix', 'server_url', 'cas_protocol_version', 'verbose_name', 'display') + #: Fields to display on the list of class:`FederatedIendityProviderAdmin` objects. list_display = ('verbose_name', 'suffix', 'display') diff --git a/cas_server/apps.py b/cas_server/apps.py index ea15273..03afab5 100644 --- a/cas_server/apps.py +++ b/cas_server/apps.py @@ -14,6 +14,12 @@ from django.apps import AppConfig class CasAppConfig(AppConfig): - """django CAS application config class""" + """ + Bases: :class:`django.apps.AppConfig` + + django CAS application config class + """ + #: Full Python path to the application. It must be unique across a Django project. name = 'cas_server' + #: Human-readable name for the application. verbose_name = _('Central Authentication Service') diff --git a/cas_server/auth.py b/cas_server/auth.py index 9f40ae4..2cb0880 100644 --- a/cas_server/auth.py +++ b/cas_server/auth.py @@ -26,55 +26,112 @@ from .models import FederatedUser class AuthUser(object): - """Authentication base class""" + """ + Authentication base class + + :param unicode username: A username, stored in the :attr:`username` class attribute. + """ + + #: username used to instanciate the current object + username = None + def __init__(self, username): self.username = username def test_password(self, password): - """test `password` agains the user""" + """ + Tests ``password`` agains the user password. + + :raises NotImplementedError: always. The method need to be implemented by subclasses + """ raise NotImplementedError() def attributs(self): - """return a dict of user attributes""" + """ + The user attributes. + + raises NotImplementedError: always. The method need to be implemented by subclasses + """ raise NotImplementedError() class DummyAuthUser(AuthUser): # pragma: no cover - """A Dummy authentication class""" + """ + A Dummy authentication class. Authentication always fails - def __init__(self, username): - super(DummyAuthUser, self).__init__(username) + :param unicode username: A username, stored in the :attr:`username` + class attribute. There is no valid value for this attribute here. + """ def test_password(self, password): - """test `password` agains the user""" + """ + Tests ``password`` agains the user password. + + :param unicode password: a clear text password as submited by the user. + :return: always ``False`` + :rtype: bool + """ return False def attributs(self): - """return a dict of user attributes""" + """ + The user attributes. + + :return: en empty :class:`dict`. + :rtype: dict + """ return {} class TestAuthUser(AuthUser): - """A test authentication class with one user test having - alose test as password and some attributes""" + """ + A test authentication class only working for one unique user. - def __init__(self, username): - super(TestAuthUser, self).__init__(username) + :param unicode username: A username, stored in the :attr:`username` + class attribute. The uniq valid value is ``settings.CAS_TEST_USER``. + """ def test_password(self, password): - """test `password` agains the user""" + """ + Tests ``password`` agains the user password. + + :param unicode password: a clear text password as submited by the user. + :return: ``True`` if :attr:`username` is valid and + ``password`` is equal to ``settings.CAS_TEST_PASSWORD``, ``False`` otherwise. + :rtype: bool + """ return self.username == settings.CAS_TEST_USER and password == settings.CAS_TEST_PASSWORD def attributs(self): - """return a dict of user attributes""" - return settings.CAS_TEST_ATTRIBUTES + """ + The user attributes. + + :return: the ``settings.CAS_TEST_ATTRIBUTES`` :class:`dict` if + :attr:`username` is valid, an empty :class:`dict` otherwise. + :rtype: dict + """ + if self.username == settings.CAS_TEST_USER: + return settings.CAS_TEST_ATTRIBUTES + else: + return {} class MysqlAuthUser(AuthUser): # pragma: no cover - """A mysql auth class: authentication user agains a mysql database""" + """ + A mysql authentication class: authentication user agains a mysql database + + :param unicode username: A username, stored in the :attr:`username` + class attribute. Valid value are fetched from the MySQL database set with + ``settings.CAS_SQL_*`` settings parameters using the query + ``settings.CAS_SQL_USER_QUERY``. + """ + #: Mysql user attributes as a :class:`dict` if the username is found in the database. user = None def __init__(self, username): + # see the connect function at + # http://mysql-python.sourceforge.net/MySQLdb.html#functions-and-attributes + # for possible mysql config parameters. mysql_config = { "user": settings.CAS_SQL_USERNAME, "passwd": settings.CAS_SQL_PASSWORD, @@ -94,7 +151,14 @@ class MysqlAuthUser(AuthUser): # pragma: no cover super(MysqlAuthUser, self).__init__(username) def test_password(self, password): - """test `password` agains the user""" + """ + Tests ``password`` agains the user password. + + :param unicode password: a clear text password as submited by the user. + :return: ``True`` if :attr:`username` is valid and ``password`` is + correct, ``False`` otherwise. + :rtype: bool + """ if self.user: return check_password( settings.CAS_SQL_PASSWORD_CHECK, @@ -106,7 +170,14 @@ class MysqlAuthUser(AuthUser): # pragma: no cover return False def attributs(self): - """return a dict of user attributes""" + """ + The user attributes. + + :return: a :class:`dict` with the user attributes. Attributes may be :func:`unicode` + or :class:`list` of :func:`unicode`. If the user do not exists, the returned + :class:`dict` is empty. + :rtype: dict + """ if self.user: return self.user else: @@ -114,7 +185,14 @@ class MysqlAuthUser(AuthUser): # pragma: no cover class DjangoAuthUser(AuthUser): # pragma: no cover - """A django auth class: authenticate user agains django internal users""" + """ + A django auth class: authenticate user agains django internal users + + :param unicode username: A username, stored in the :attr:`username` + class attribute. Valid value are usernames of django internal users. + """ + #: a django user object if the username is found. The user model is retreived + #: using :func:`django.contrib.auth.get_user_model`. user = None def __init__(self, username): @@ -126,14 +204,27 @@ class DjangoAuthUser(AuthUser): # pragma: no cover super(DjangoAuthUser, self).__init__(username) def test_password(self, password): - """test `password` agains the user""" + """ + Tests ``password`` agains the user password. + + :param unicode password: a clear text password as submited by the user. + :return: ``True`` if :attr:`user` is valid and ``password`` is + correct, ``False`` otherwise. + :rtype: bool + """ if self.user: return self.user.check_password(password) else: return False def attributs(self): - """return a dict of user attributes""" + """ + The user attributes, defined as the fields on the :attr:`user` object. + + :return: a :class:`dict` with the :attr:`user` object fields. Attributes may be + If the user do not exists, the returned :class:`dict` is empty. + :rtype: dict + """ if self.user: attr = {} for field in self.user._meta.fields: @@ -144,7 +235,16 @@ class DjangoAuthUser(AuthUser): # pragma: no cover class CASFederateAuth(AuthUser): - """Authentication class used then CAS_FEDERATE is True""" + """ + Authentication class used then CAS_FEDERATE is True + + :param unicode username: A username, stored in the :attr:`username` + class attribute. Valid value are usernames of + :class:`FederatedUser` object. + :class:`FederatedUser` object are created on CAS + backends successful ticket validation. + """ + #: a :class`FederatedUser` object if ``username`` is found. user = None def __init__(self, username): @@ -157,7 +257,17 @@ class CASFederateAuth(AuthUser): super(CASFederateAuth, self).__init__(username) def test_password(self, ticket): - """test `password` agains the user""" + """ + Tests ``password`` agains the user password. + + :param unicode password: The CAS tickets just used to validate the user authentication + against its CAS backend. + :return: ``True`` if :attr:`user` is valid and ``password`` is + a ticket validated less than ``settings.CAS_TICKET_VALIDITY`` secondes and has not + being previously used for authenticated this + :class:`FederatedUser`. ``False`` otherwise. + :rtype: bool + """ if not self.user or not self.user.ticket: return False else: @@ -168,7 +278,13 @@ class CASFederateAuth(AuthUser): ) def attributs(self): - """return a dict of user attributes""" + """ + The user attributes, as returned by the CAS backend. + + :return: :obj:`FederatedUser.attributs`. + If the user do not exists, the returned :class:`dict` is empty. + :rtype: dict + """ if not self.user: # pragma: no cover (should not happen) return {} else: diff --git a/cas_server/federate.py b/cas_server/federate.py index 74528cb..95ef3c7 100644 --- a/cas_server/federate.py +++ b/cas_server/federate.py @@ -10,25 +10,32 @@ # # (c) 2016 Valentin Samir """federated mode helper classes""" -from .default_settings import settings +from .default_settings import SessionStore from django.db import IntegrityError from .cas import CASClient from .models import FederatedUser, FederateSLO, User import logging -from importlib import import_module from six.moves import urllib -SessionStore = import_module(settings.SESSION_ENGINE).SessionStore - +#: logger facility logger = logging.getLogger(__name__) class CASFederateValidateUser(object): - """Class CAS client used to authenticate the user again a CAS provider""" + """ + Class CAS client used to authenticate the user again a CAS provider + + :param cas_server.models.FederatedIendityProvider provider: The provider to use for + authenticate the user. + :param unicode service_url: The service url to transmit to the ``provider``. + """ + #: the provider returned username username = None + #: the provider returned attributes attributs = {} + #: the CAS client instance client = None def __init__(self, provider, service_url): @@ -41,15 +48,31 @@ class CASFederateValidateUser(object): ) def get_login_url(self): - """return the CAS provider login url""" + """ + :return: the CAS provider login url + :rtype: unicode + """ return self.client.get_login_url() def get_logout_url(self, redirect_url=None): - """return the CAS provider logout url""" + """ + :param redirect_url: The url to redirect to after logout from the provider, if provided. + :type redirect_url: :obj:`unicode` or :obj:`NoneType` + :return: the CAS provider logout url + :rtype: unicode + """ return self.client.get_logout_url(redirect_url) def verify_ticket(self, ticket): - """test `ticket` agains the CAS provider, if valid, create the local federated user""" + """ + test ``ticket`` agains the CAS provider, if valid, create a + :class:`FederatedUser` matching provider returned + username and attributes. + + :param unicode ticket: The ticket to validate against the provider CAS + :return: ``True`` if the validation succeed, else ``False``. + :rtype: bool + """ try: username, attributs = self.client.verify_ticket(ticket)[:2] except urllib.error.URLError: @@ -73,7 +96,15 @@ class CASFederateValidateUser(object): @staticmethod def register_slo(username, session_key, ticket): - """association a ticket with a (username, session) for processing later SLO request""" + """ + association a ``ticket`` with a (``username``, ``session_key``) for processing later SLO + request by creating a :class:`cas_server.models.FederateSLO` object. + + :param unicode username: A logged user username, with the ``@`` component. + :param unicode session_key: A logged user session_key matching ``username``. + :param unicode ticket: A ticket used to authentication ``username`` for the session + ``session_key``. + """ try: FederateSLO.objects.create( username=username, @@ -84,7 +115,14 @@ class CASFederateValidateUser(object): pass def clean_sessions(self, logout_request): - """process a SLO request""" + """ + process a SLO request: Search for ticket values in ``logout_request``. For each + ticket value matching a :class:`cas_server.models.FederateSLO`, disconnect the + corresponding user. + + :param unicode logout_request: An XML document contening one or more Single Log Out + requests. + """ try: slos = self.client.get_saml_slos(logout_request) or [] except NameError: # pragma: no cover (should not happen) diff --git a/cas_server/forms.py b/cas_server/forms.py index 5284fac..4b35008 100644 --- a/cas_server/forms.py +++ b/cas_server/forms.py @@ -19,20 +19,33 @@ import cas_server.models as models class WarnForm(forms.Form): - """Form used on warn page before emiting a ticket""" + """ + Bases: :class:`django.forms.Form` + + Form used on warn page before emiting a ticket + """ + + #: The service url for which the user want a ticket service = forms.CharField(widget=forms.HiddenInput(), required=False) + #: Is the service asking the authentication renewal ? renew = forms.BooleanField(widget=forms.HiddenInput(), required=False) + #: Url to redirect to if the authentication fail (user not authenticated or bad service) gateway = forms.CharField(widget=forms.HiddenInput(), required=False) method = forms.CharField(widget=forms.HiddenInput(), required=False) + #: ``True`` if the user has been warned of the ticket emission warned = forms.BooleanField(widget=forms.HiddenInput(), required=False) + #: A valid LoginTicket to prevent POST replay lt = forms.CharField(widget=forms.HiddenInput(), required=False) class FederateSelect(forms.Form): """ - Form used on the login page when CAS_FEDERATE is True - allowing the user to choose a identity provider. + Bases: :class:`django.forms.Form` + + Form used on the login page when ``settings.CAS_FEDERATE`` is ``True`` + allowing the user to choose an identity provider. """ + #: The providers the user can choose to be used as authentication backend provider = forms.ModelChoiceField( queryset=models.FederatedIendityProvider.objects.filter(display=True).order_by( "pos", @@ -42,27 +55,49 @@ class FederateSelect(forms.Form): to_field_name="suffix", label=_('Identity provider'), ) + #: The service url for which the user want a ticket service = forms.CharField(label=_('service'), widget=forms.HiddenInput(), required=False) method = forms.CharField(widget=forms.HiddenInput(), required=False) + #: A checkbox to remember the user choices of :attr:`provider` remember = forms.BooleanField(label=_('Remember the identity provider'), required=False) + #: A checkbox to ask to be warn before emiting a ticket for another service warn = forms.BooleanField(label=_('warn'), required=False) + #: Is the service asking the authentication renewal ? renew = forms.BooleanField(widget=forms.HiddenInput(), required=False) class UserCredential(forms.Form): - """Form used on the login page to retrive user credentials""" + """ + Bases: :class:`django.forms.Form` + + Form used on the login page to retrive user credentials + """ + #: The user username username = forms.CharField(label=_('login')) + #: The service url for which the user want a ticket service = forms.CharField(label=_('service'), widget=forms.HiddenInput(), required=False) + #: The user password password = forms.CharField(label=_('password'), widget=forms.PasswordInput) + #: A valid LoginTicket to prevent POST replay lt = forms.CharField(widget=forms.HiddenInput(), required=False) method = forms.CharField(widget=forms.HiddenInput(), required=False) + #: A checkbox to ask to be warn before emiting a ticket for another service warn = forms.BooleanField(label=_('warn'), required=False) + #: Is the service asking the authentication renewal ? renew = forms.BooleanField(widget=forms.HiddenInput(), required=False) def __init__(self, *args, **kwargs): super(UserCredential, self).__init__(*args, **kwargs) def clean(self): + """ + Validate that the submited :attr:`username` and :attr:`password` are valid + + :raises django.forms.ValidationError: if the :attr:`username` and :attr:`password` + are not valid. + :return: The cleaned POST data + :rtype: dict + """ cleaned_data = super(UserCredential, self).clean() auth = utils.import_attr(settings.CAS_AUTH_CLASS)(cleaned_data.get("username")) if auth.test_password(cleaned_data.get("password")): @@ -73,17 +108,51 @@ class UserCredential(forms.Form): class FederateUserCredential(UserCredential): - """Form used on the login page to retrive user credentials""" + """ + Bases: :class:`UserCredential` + + Form used on a auto submited page for linking the views + :class:`FederateAuth` and + :class:`LoginView`. + + On successful authentication on a provider, in the view + :class:`FederateAuth` a + :class:`FederatedUser` is created by + :meth:`cas_server.federate.CASFederateValidateUser.verify_ticket` and the user is redirected + to :class:`LoginView`. This form is then automatically filled + with infos matching the created :class:`FederatedUser` + using the ``ticket`` as one time password and submited using javascript. If javascript is + not enabled, a connect button is displayed. + + This stub authentication form, allow to implement the federated mode with very few + modificatons to the :class:`LoginView` view. + """ + #: the user username with the ``@`` component username = forms.CharField(widget=forms.HiddenInput()) + #: The service url for which the user want a ticket service = forms.CharField(widget=forms.HiddenInput(), required=False) + #: The ``ticket`` used to authenticate the user against a provider password = forms.CharField(widget=forms.HiddenInput()) + #: alias of :attr:`password` ticket = forms.CharField(widget=forms.HiddenInput()) + #: A valid LoginTicket to prevent POST replay lt = forms.CharField(widget=forms.HiddenInput(), required=False) method = forms.CharField(widget=forms.HiddenInput(), required=False) + #: Has the user asked to be warn before emiting a ticket for another service warn = forms.BooleanField(widget=forms.HiddenInput(), required=False) + #: Is the service asking the authentication renewal ? renew = forms.BooleanField(widget=forms.HiddenInput(), required=False) def clean(self): + """ + Validate that the submited :attr:`username` and :attr:`password` are valid using + the :class:`CASFederateAuth` auth class. + + :raises django.forms.ValidationError: if the :attr:`username` and :attr:`password` + do not correspond to a :class:`FederatedUser`. + :return: The cleaned POST data + :rtype: dict + """ cleaned_data = super(FederateUserCredential, self).clean() try: user = models.FederatedUser.get_from_federated_username(cleaned_data["username"]) @@ -99,7 +168,11 @@ class FederateUserCredential(UserCredential): class TicketForm(forms.ModelForm): - """Form for Tickets in the admin interface""" + """ + Bases: :class:`django.forms.ModelForm` + + Form for Tickets in the admin interface + """ class Meta: model = models.Ticket exclude = [] diff --git a/cas_server/models.py b/cas_server/models.py index d741bf2..23b9587 100644 --- a/cas_server/models.py +++ b/cas_server/models.py @@ -10,7 +10,7 @@ # # (c) 2015-2016 Valentin Samir """models for the app""" -from .default_settings import settings +from .default_settings import settings, SessionStore from django.db import models from django.db.models import Q @@ -23,36 +23,42 @@ from picklefield.fields import PickledObjectField import re import sys import logging -from importlib import import_module from datetime import timedelta from concurrent.futures import ThreadPoolExecutor from requests_futures.sessions import FuturesSession import cas_server.utils as utils -SessionStore = import_module(settings.SESSION_ENGINE).SessionStore - +#: logger facility logger = logging.getLogger(__name__) @python_2_unicode_compatible class FederatedIendityProvider(models.Model): """ + Bases: :class:`django.db.models.Model` + An identity provider for the federated mode """ class Meta: verbose_name = _(u"identity provider") verbose_name_plural = _(u"identity providers") + #: Suffix append to backend CAS returned username: ``returned_username`` @ ``suffix``. + #: it must be unique. suffix = models.CharField( max_length=30, unique=True, verbose_name=_(u"suffix"), help_text=_( - u"Suffix append to backend CAS returner " + u"Suffix append to backend CAS returned " u"username: ``returned_username`` @ ``suffix``." ) ) + #: URL to the root of the CAS server application. If login page is + #: https://cas.example.net/cas/login then :attr:`server_url` should be + #: https://cas.example.net/cas/ server_url = models.CharField(max_length=255, verbose_name=_(u"server url")) + #: Version of the CAS protocol to use when sending requests the the backend CAS. cas_protocol_version = models.CharField( max_length=30, choices=[ @@ -67,11 +73,14 @@ class FederatedIendityProvider(models.Model): ), default="3" ) + #: Name for this identity provider displayed on the login page. verbose_name = models.CharField( max_length=255, verbose_name=_(u"verbose name"), help_text=_(u"Name for this identity provider displayed on the login page.") ) + #: Position of the identity provider on the login page. Identity provider are sorted using the + #: (:attr:`pos`, :attr:`verbose_name`, :attr:`suffix`) attributes. pos = models.IntegerField( default=100, verbose_name=_(u"position"), @@ -83,6 +92,9 @@ class FederatedIendityProvider(models.Model): ) ) ) + #: Display the provider on the login page. Beware that this do not disable the identity + #: provider, it just hide it on the login page. User will always be able to log in using this + #: provider by fetching ``/federate/suffix``. display = models.BooleanField( default=True, verbose_name=_(u"display"), @@ -99,23 +111,40 @@ class FederatedIendityProvider(models.Model): :param unicode username: A CAS backend returned username :param unicode suffix: A suffix identifying the CAS backend + :return: The federated username: ``username`` @ ``suffix``. + :rtype: unicode """ return u'%s@%s' % (username, suffix) def build_username(self, username): - """Transform backend username into federated username""" + """ + Transform backend username into federated username + + :param unicode username: A CAS backend returned username + :return: The federated username: ``username`` @ :attr:`suffix`. + :rtype: unicode + """ return u'%s@%s' % (username, self.suffix) @python_2_unicode_compatible class FederatedUser(models.Model): - """A federated user as returner by a CAS provider (username and attributes)""" + """ + Bases: :class:`django.db.models.Model` + + A federated user as returner by a CAS provider (username and attributes) + """ class Meta: unique_together = ("username", "provider") + #: The user username returned by the CAS backend on successful ticket validation username = models.CharField(max_length=124) + #: A foreign key to :class:`FederatedIendityProvider` provider = models.ForeignKey(FederatedIendityProvider, on_delete=models.CASCADE) + #: The user attributes returned by the CAS backend on successful ticket validation attributs = PickledObjectField() + #: The last ticket used to authenticate :attr:`username` against :attr:`provider` ticket = models.CharField(max_length=255) + #: Last update timespampt. Usually, the last time :attr:`ticket` has been set. last_update = models.DateTimeField(auto_now=True) def __str__(self): @@ -123,12 +152,15 @@ class FederatedUser(models.Model): @property def federated_username(self): - """return the federated username with a suffix""" + """The federated username with a suffix for the current :class:`FederatedUser`.""" return self.provider.build_username(self.username) @classmethod def get_from_federated_username(cls, username): - """return a FederatedUser object from a federated username""" + """ + :return: A :class:`FederatedUser` object from a federated ``username`` + :rtype: :class:`FederatedUser` + """ if username is None: raise cls.DoesNotExist() else: @@ -143,7 +175,7 @@ class FederatedUser(models.Model): @classmethod def clean_old_entries(cls): - """remove old unused federated users""" + """remove old unused :class:`FederatedUser`""" federated_users = cls.objects.filter( last_update__lt=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_TIMEOUT)) ) @@ -154,16 +186,23 @@ class FederatedUser(models.Model): class FederateSLO(models.Model): - """An association between a CAS provider ticket and a (username, session) for processing SLO""" + """ + Bases: :class:`django.db.models.Model` + + An association between a CAS provider ticket and a (username, session) for processing SLO + """ class Meta: unique_together = ("username", "session_key", "ticket") + #: the federated username with the ``@``component username = models.CharField(max_length=30) + #: the session key for the session :attr:`username` has been authenticated using :attr:`ticket` session_key = models.CharField(max_length=40, blank=True, null=True) + #: The ticket used to authenticate :attr:`username` ticket = models.CharField(max_length=255, db_index=True) @classmethod def clean_deleted_sessions(cls): - """remove old object for which the session do not exists anymore""" + """remove old :class:`FederateSLO` object for which the session do not exists anymore""" for federate_slo in cls.objects.all(): if not SessionStore(session_key=federate_slo.session_key).get('authenticated'): federate_slo.delete() @@ -171,17 +210,27 @@ class FederateSLO(models.Model): @python_2_unicode_compatible class User(models.Model): - """A user logged into the CAS""" + """ + Bases: :class:`django.db.models.Model` + + A user logged into the CAS + """ class Meta: unique_together = ("username", "session_key") verbose_name = _("User") verbose_name_plural = _("Users") + #: The session key of the current authenticated user session_key = models.CharField(max_length=40, blank=True, null=True) + #: The username of the current authenticated user username = models.CharField(max_length=30) + #: Last time the authenticated user has do something (auth, fetch ticket, etc…) date = models.DateTimeField(auto_now=True) def delete(self, *args, **kwargs): - """remove the User""" + """ + Remove the current :class:`User`. If ``settings.CAS_FEDERATE`` is ``True``, also delete + the corresponding :class:`FederateSLO` object. + """ if settings.CAS_FEDERATE: FederateSLO.objects.filter( username=self.username, @@ -191,7 +240,10 @@ class User(models.Model): @classmethod def clean_old_entries(cls): - """Remove users inactive since more that SESSION_COOKIE_AGE""" + """ + Remove :class:`User` objects inactive since more that + :django:setting:`SESSION_COOKIE_AGE` and send corresponding SingleLogOut requests. + """ users = cls.objects.filter( date__lt=(timezone.now() - timedelta(seconds=settings.SESSION_COOKIE_AGE)) ) @@ -201,7 +253,7 @@ class User(models.Model): @classmethod def clean_deleted_sessions(cls): - """Remove user where the session do not exists anymore""" + """Remove :class:`User` objects where the corresponding session do not exists anymore.""" for user in cls.objects.all(): if not SessionStore(session_key=user.session_key).get('authenticated'): user.logout() @@ -209,14 +261,22 @@ class User(models.Model): @property def attributs(self): - """return a fresh dict for the user attributs""" + """ + Property. + A fresh :class:`dict` for the user attributes, using ``settings.CAS_AUTH_CLASS`` + """ return utils.import_attr(settings.CAS_AUTH_CLASS)(self.username).attributs() def __str__(self): return u"%s - %s" % (self.username, self.session_key) def logout(self, request=None): - """Sending SLO request to all services the user logged in""" + """ + Send SLO requests to all services the user is logged in. + + :param request: The current django HttpRequest to display possible failure to the user. + :type request: :class:`django.http.HttpRequest` or :obj:`NoneType` + """ async_list = [] session = FuturesSession( executor=ThreadPoolExecutor(max_workers=settings.CAS_SLO_MAX_PARALLEL_REQUESTS) @@ -249,9 +309,22 @@ class User(models.Model): def get_ticket(self, ticket_class, service, service_pattern, renew): """ - Generate a ticket using `ticket_class` for the service - `service` matching `service_pattern` and asking or not for - authentication renewal with `renew` + Generate a ticket using ``ticket_class`` for the service + ``service`` matching ``service_pattern`` and asking or not for + authentication renewal with ``renew`` + + :param type ticket_class: :class:`ServiceTicket` or :class:`ProxyTicket` or + :class:`ProxyGrantingTicket`. + :param unicode service: The service url for which we want a ticket. + :param ServicePattern service_pattern: The service pattern matching ``service``. + Beware that ``service`` must match :attr:`ServicePattern.pattern` and the current + :class:`User` must pass :meth:`ServicePattern.check_user`. These checks are not done + here and you must perform them before calling this method. + :param bool renew: Should be ``True`` if authentication has been renewed. Must be + ``False`` otherwise. + :return: A :class:`Ticket` object. + :rtype: :class:`ServiceTicket` or :class:`ProxyTicket` or + :class:`ProxyGrantingTicket`. """ attributs = dict( (a.name, a.replace if a.replace else a.name) for a in service_pattern.attributs.all() @@ -286,8 +359,20 @@ class User(models.Model): return ticket def get_service_url(self, service, service_pattern, renew): - """Return the url to which the user must be redirected to - after a Service Ticket has been generated""" + """ + Return the url to which the user must be redirected to + after a Service Ticket has been generated + + :param unicode service: The service url for which we want a ticket. + :param ServicePattern service_pattern: The service pattern matching ``service``. + Beware that ``service`` must match :attr:`ServicePattern.pattern` and the current + :class:`User` must pass :meth:`ServicePattern.check_user`. These checks are not done + here and you must perform them before calling this method. + :param bool renew: Should be ``True`` if authentication has been renewed. Must be + ``False`` otherwise. + :return unicode: The service url with the ticket GET param added. + :rtype: unicode + """ ticket = self.get_ticket(ServiceTicket, service, service_pattern, renew) url = utils.update_url(service, {'ticket': ticket.value}) logger.info("Service ticket created for service %s by user %s." % (service, self.username)) @@ -295,41 +380,60 @@ class User(models.Model): class ServicePatternException(Exception): - """Base exception of exceptions raised in the ServicePattern model""" + """ + Bases: :class:`exceptions.Exception` + + Base exception of exceptions raised in the ServicePattern model""" pass class BadUsername(ServicePatternException): - """Exception raised then an non allowed username - try to get a ticket for a service""" + """ + Bases: :class:`ServicePatternException` + + Exception raised then an non allowed username try to get a ticket for a service + """ pass class BadFilter(ServicePatternException): - """"Exception raised then a user try - to get a ticket for a service and do not reach a condition""" + """ + Bases: :class:`ServicePatternException` + + Exception raised then a user try to get a ticket for a service and do not reach a condition + """ pass class UserFieldNotDefined(ServicePatternException): - """Exception raised then a user try to get a ticket for a service - using as username an attribut not present on this user""" + """ + Bases: :class:`ServicePatternException` + + Exception raised then a user try to get a ticket for a service using as username + an attribut not present on this user + """ pass @python_2_unicode_compatible class ServicePattern(models.Model): - """Allowed services pattern agains services are tested to""" + """ + Bases: :class:`django.db.models.Model` + + Allowed services pattern agains services are tested to + """ class Meta: ordering = ("pos", ) verbose_name = _("Service pattern") verbose_name_plural = _("Services patterns") + #: service patterns are sorted using the :attr:`pos` attribute pos = models.IntegerField( default=100, verbose_name=_(u"position"), help_text=_(u"service patterns are sorted using the position attribute") ) + #: A name for the service (this can bedisplayed to the user on the login page) name = models.CharField( max_length=255, unique=True, @@ -338,6 +442,9 @@ class ServicePattern(models.Model): verbose_name=_(u"name"), help_text=_(u"A name for the service") ) + #: A regular expression matching services. "Will usually looks like + #: '^https://some\\.server\\.com/path/.*$'. As it is a regular expression, special character + #: must be escaped with a '\\'. pattern = models.CharField( max_length=255, unique=True, @@ -348,6 +455,7 @@ class ServicePattern(models.Model): "As it is a regular expression, special character must be escaped with a '\\'." ) ) + #: Name of the attribut to transmit as username, if empty the user login is used user_field = models.CharField( max_length=255, default="", @@ -355,27 +463,35 @@ class ServicePattern(models.Model): verbose_name=_(u"user field"), help_text=_("Name of the attribut to transmit as username, empty = login") ) + #: A boolean allowing to limit username allowed to connect to :attr:`usernames`. restrict_users = models.BooleanField( default=False, verbose_name=_(u"restrict username"), help_text=_("Limit username allowed to connect to the list provided bellow") ) + #: A boolean allowing to deliver :class:`ProxyTicket` to the service. proxy = models.BooleanField( default=False, verbose_name=_(u"proxy"), help_text=_("Proxy tickets can be delivered to the service") ) + #: A boolean allowing the service to be used as a proxy callback (via the pgtUrl GET param) + #: to deliver :class:`ProxyGrantingTicket`. proxy_callback = models.BooleanField( default=False, verbose_name=_(u"proxy callback"), help_text=_("can be used as a proxy callback to deliver PGT") ) + #: Enable SingleLogOut for the service. Old validaed tickets for the service will be kept + #: until ``settings.CAS_TICKET_TIMEOUT`` after what a SLO request is send to the service and + #: the ticket is purged from database. A SLO can be send earlier if the user log-out. single_log_out = models.BooleanField( default=False, verbose_name=_(u"single log out"), help_text=_("Enable SLO for the service") ) - + #: An URL where the SLO request will be POST. If empty the service url will be used. + #: This is usefull for non HTTP proxied services like smtp or imap. single_log_out_callback = models.CharField( max_length=255, default="", @@ -393,7 +509,15 @@ class ServicePattern(models.Model): 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: a :class:`User` object + :param User user: a :class:`User` object + :raises BadUsername: if :attr:`restrict_users` if ``True`` and :attr:`User.username` + is not within :attr:`usernames`. + :raises BadFilter: if a :class:`FilterAttributValue` condition of :attr:`filters` + connot be verified. + :raises UserFieldNotDefined: if :attr:`user_field` is defined and its value is not + within :attr:`User.attributs`. + :return: ``True`` + :rtype: bool """ if self.restrict_users and not self.usernames.filter(value=user.username): logger.warning("Username %s not allowed on service %s" % (user.username, self.name)) @@ -434,8 +558,15 @@ class ServicePattern(models.Model): @classmethod def validate(cls, service): - """Check if a Service Patern match `service` and - return it, else raise `ServicePattern.DoesNotExist`""" + """ + Get a :class:`ServicePattern` intance from a service url. + + :param unicode service: A service url + :return: A :class:`ServicePattern` instance matching ``service``. + :rtype: :class:`ServicePattern` + :raises ServicePattern.DoesNotExist: if no :class:`ServicePattern` is matching + ``service``. + """ for service_pattern in cls.objects.all().order_by('pos'): if re.match(service_pattern.pattern, service): return service_pattern @@ -445,12 +576,20 @@ class ServicePattern(models.Model): @python_2_unicode_compatible class Username(models.Model): - """A list of allowed usernames on a service pattern""" + """ + Bases: :class:`django.db.models.Model` + + A list of allowed usernames on a :class:`ServicePattern` + """ + #: username allowed to connect to the service value = models.CharField( max_length=255, verbose_name=_(u"username"), help_text=_(u"username allowed to connect to the service") ) + #: ForeignKey to a :class:`ServicePattern`. :class:`Username` instances for a + #: :class:`ServicePattern` are accessible thought its :attr:`ServicePattern.usernames` + #: attribute. service_pattern = models.ForeignKey(ServicePattern, related_name="usernames") def __str__(self): @@ -459,14 +598,23 @@ class Username(models.Model): @python_2_unicode_compatible class ReplaceAttributName(models.Model): - """A list of replacement of attributs name for a service pattern""" + """ + Bases: :class:`django.db.models.Model` + + A replacement of an attribute name for a :class:`ServicePattern`. It also tell to transmit + an attribute of :attr:`User.attributs` to the service. An empty :attr:`replace` mean + to use the original attribute name. + """ class Meta: unique_together = ('name', 'replace', 'service_pattern') + #: Name the attribute: a key of :attr:`User.attributs` name = models.CharField( max_length=255, verbose_name=_(u"name"), help_text=_(u"name of an attribut to send to the service, use * for all attributes") ) + #: The name of the attribute to transmit to the service. If empty, the value of :attr:`name` + #: is used. replace = models.CharField( max_length=255, blank=True, @@ -474,6 +622,9 @@ class ReplaceAttributName(models.Model): help_text=_(u"name under which the attribut will be show" u"to the service. empty = default name of the attribut") ) + #: ForeignKey to a :class:`ServicePattern`. :class:`ReplaceAttributName` instances for a + #: :class:`ServicePattern` are accessible thought its :attr:`ServicePattern.attributs` + #: attribute. service_pattern = models.ForeignKey(ServicePattern, related_name="attributs") def __str__(self): @@ -485,17 +636,29 @@ class ReplaceAttributName(models.Model): @python_2_unicode_compatible class FilterAttributValue(models.Model): - """A list of filter on attributs for a service pattern""" + """ + Bases: :class:`django.db.models.Model` + + A filter on :attr:`User.attributs` for a :class:`ServicePattern`. If a :class:`User` do not + have an attribute :attr:`attribut` or its value do not match :attr:`pattern`, then + :meth:`ServicePattern.check_user` will raises :class:`BadFilter` if called with that user. + """ + #: The name of a user attribute attribut = models.CharField( max_length=255, verbose_name=_(u"attribut"), help_text=_(u"Name of the attribut which must verify pattern") ) + #: A regular expression the attribute :attr:`attribut` value must verify. If :attr:`attribut` + #: if a list, only one of the list values needs to match. pattern = models.CharField( max_length=255, verbose_name=_(u"pattern"), help_text=_(u"a regular expression") ) + #: ForeignKey to a :class:`ServicePattern`. :class:`FilterAttributValue` instances for a + #: :class:`ServicePattern` are accessible thought its :attr:`ServicePattern.filters` + #: attribute. service_pattern = models.ForeignKey(ServicePattern, related_name="filters") def __str__(self): @@ -504,23 +667,34 @@ class FilterAttributValue(models.Model): @python_2_unicode_compatible class ReplaceAttributValue(models.Model): - """Replacement to apply on attributs values for a service pattern""" + """ + Bases: :class:`django.db.models.Model` + + A replacement (using a regular expression) of an attribute value for a + :class:`ServicePattern`. + """ + #: Name the attribute: a key of :attr:`User.attributs` attribut = models.CharField( max_length=255, verbose_name=_(u"attribut"), help_text=_(u"Name of the attribut for which the value must be replace") ) + #: A regular expression matching the part of the attribute value that need to be changed pattern = models.CharField( max_length=255, verbose_name=_(u"pattern"), help_text=_(u"An regular expression maching whats need to be replaced") ) + #: The replacement to what is mached by :attr:`pattern`. groups are capture by \\1, \\2 … replace = models.CharField( max_length=255, blank=True, verbose_name=_(u"replace"), help_text=_(u"replace expression, groups are capture by \\1, \\2 …") ) + #: ForeignKey to a :class:`ServicePattern`. :class:`ReplaceAttributValue` instances for a + #: :class:`ServicePattern` are accessible thought its :attr:`ServicePattern.replacements` + #: attribute. service_pattern = models.ForeignKey(ServicePattern, related_name="replacements") def __str__(self): @@ -529,19 +703,37 @@ class ReplaceAttributValue(models.Model): @python_2_unicode_compatible class Ticket(models.Model): - """Generic class for a Ticket""" + """ + Bases: :class:`django.db.models.Model` + + Generic class for a Ticket + """ class Meta: abstract = True + #: ForeignKey to a :class:`User`. user = models.ForeignKey(User, related_name="%(class)s") + #: The user attributes to be transmited to the service on successful validation attributs = PickledObjectField() + #: A boolean. ``True`` if the ticket has been validated validate = models.BooleanField(default=False) + #: The service url for the ticket service = models.TextField() + #: ForeignKey to a :class:`ServicePattern`. The :class:`ServicePattern` corresponding to + #: :attr:`service`. Use :meth:`ServicePattern.validate` to find it. service_pattern = models.ForeignKey(ServicePattern, related_name="%(class)s") + #: Date of the ticket creation creation = models.DateTimeField(auto_now_add=True) + #: A boolean. ``True`` if the user has just renew his authentication renew = models.BooleanField(default=False) + #: A boolean. Set to :attr:`service_pattern` attribute + #: :attr:`ServicePattern.single_log_out` value. single_log_out = models.BooleanField(default=False) + #: Max duration between ticket creation and its validation. Any validation attempt for the + #: ticket after :attr:`creation` + VALIDITY will fail as if the ticket do not exists. VALIDITY = settings.CAS_TICKET_VALIDITY + #: Time we keep ticket with :attr:`single_log_out` set to ``True`` before sending SingleLogOut + #: requests. TIMEOUT = settings.CAS_TICKET_TIMEOUT def __str__(self): @@ -615,6 +807,14 @@ class Ticket(models.Model): @staticmethod def get_class(ticket): + """ + Return the ticket class of ``ticket`` + + :param unicode ticket: A ticket + :return: The class corresponding to ``ticket`` (:class:`ServiceTicket` or + :class:`ProxyTicket` or :class:`ProxyGrantingTicket`) if found, ``None`` otherwise. + :rtype: :obj:`type` or :obj:`NoneType` + """ for ticket_class in [ServiceTicket, ProxyTicket, ProxyGrantingTicket]: if ticket.startswith(ticket_class.PREFIX): return ticket_class @@ -622,8 +822,14 @@ class Ticket(models.Model): @python_2_unicode_compatible class ServiceTicket(Ticket): - """A Service Ticket""" + """ + Bases: :class:`Ticket` + + A Service Ticket + """ + #: The ticket prefix used to differentiate it from other tickets types PREFIX = settings.CAS_SERVICE_TICKET_PREFIX + #: The ticket value value = models.CharField(max_length=255, default=utils.gen_st, unique=True) def __str__(self): @@ -632,8 +838,14 @@ class ServiceTicket(Ticket): @python_2_unicode_compatible class ProxyTicket(Ticket): - """A Proxy Ticket""" + """ + Bases: :class:`Ticket` + + A Proxy Ticket + """ + #: The ticket prefix used to differentiate it from other tickets types PREFIX = settings.CAS_PROXY_TICKET_PREFIX + #: The ticket value value = models.CharField(max_length=255, default=utils.gen_pt, unique=True) def __str__(self): @@ -642,9 +854,17 @@ class ProxyTicket(Ticket): @python_2_unicode_compatible class ProxyGrantingTicket(Ticket): - """A Proxy Granting Ticket""" + """ + Bases: :class:`Ticket` + + A Proxy Granting Ticket + """ + #: The ticket prefix used to differentiate it from other tickets types PREFIX = settings.CAS_PROXY_GRANTING_TICKET_PREFIX + #: ProxyGranting ticket are never validated. However, they can be used during :attr:`VALIDITY` + #: to get :class:`ProxyTicket` for :attr:`user` VALIDITY = settings.CAS_PGT_VALIDITY + #: The ticket value value = models.CharField(max_length=255, default=utils.gen_pgt, unique=True) def __str__(self): @@ -653,10 +873,18 @@ class ProxyGrantingTicket(Ticket): @python_2_unicode_compatible class Proxy(models.Model): - """A list of proxies on `ProxyTicket`""" + """ + Bases: :class:`django.db.models.Model` + + A list of proxies on :class:`ProxyTicket` + """ class Meta: ordering = ("-pk", ) + #: Service url of the PGT used for getting the associated :class:`ProxyTicket` url = models.CharField(max_length=255) + #: ForeignKey to a :class:`ProxyTicket`. :class:`Proxy` instances for a + #: :class:`ProxyTicket` are accessible thought its :attr:`ProxyTicket.proxies` + #: attribute. proxy_ticket = models.ForeignKey(ProxyTicket, related_name="proxies") def __str__(self): diff --git a/cas_server/utils.py b/cas_server/utils.py index 72f1369..259174f 100644 --- a/cas_server/utils.py +++ b/cas_server/utils.py @@ -30,13 +30,27 @@ from six.moves.urllib.parse import urlparse, urlunparse, parse_qsl, urlencode 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 return params 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"] = [] for msg in messages.get_messages(request): data["messages"].append({'message': msg.message, 'level': msg.level_tag}) @@ -44,7 +58,13 @@ def json_response(request, data): 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): return path if "." not in path: @@ -59,24 +79,50 @@ def import_attr(path): 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` + :return: A redirection to the URL with name ``url_name`` with ``params`` as querystring. + :rtype: django.http.HttpResponseRedirect + """ url = reverse(url_name) params = urlencode(params if params else {}) return HttpResponseRedirect(url + "?%s" % params) 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` + :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) params = urlencode(params if params else {}) if params: - return url + "?%s" % params + return u"%s?%s" % (url, params) else: return url 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` + :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: ignore = set() params = {} @@ -87,7 +133,14 @@ def copy_params(get_or_post_params, ignore=None): 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( datetime.utcnow() + timedelta(seconds=max_age), "%a, %d-%b-%Y %H:%M:%S GMT" @@ -103,20 +156,36 @@ def set_cookie(response, key, value, max_age): 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: ignore_params = set() - protocol = 'https' if request.is_secure() else "http" - service_url = "%s://%s%s" % (protocol, request.get_host(), request.path) + protocol = u'https' if request.is_secure() else u"http" + service_url = u"%s://%s%s" % (protocol, request.get_host(), request.path) if request.GET: params = copy_params(request.GET, ignore_params) if params: - service_url += "?%s" % urlencode(params) + service_url += u"?%s" % urlencode(params) return service_url 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): url = url.encode('utf-8') for key, value in list(params.items()): @@ -140,7 +209,12 @@ def update_url(url, params): 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 while True: if error.args[i:]: @@ -154,52 +228,97 @@ def unpack_nested_exception(error): return error -def _gen_ticket(prefix, lg=settings.CAS_TICKET_LEN): - """Generate a ticket with prefix `prefix`""" - return '%s-%s' % ( - prefix, - ''.join( - random.choice( - string.ascii_letters + string.digits - ) for _ in range(lg - len(prefix) - 1) - ) +def _gen_ticket(prefix=None, lg=settings.CAS_TICKET_LEN): + """ + Generate a ticket with prefix ``prefix`` and length ``lg`` + + :param unicode prefix: An optional prefix (probably ST, PT, PGT or PGTIOU) + :param int lg: The length of the generated ticket (with the prefix) + :return: A randomlly generated ticket of length ``lg`` + :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(): - """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) 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) 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) 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) 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) 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): """ - return the value in index `index` of the tuple `nuplet` if it exists, - else return `default` + :param tuple nuplet: A tuple + :param int index: An index + :param default: An optional default value + :return: ``nuplet[index]`` if defined, else ``default`` (possibly ``None``) """ if nuplet is None: return default @@ -210,7 +329,13 @@ def get_tuple(nuplet, index, default=None): 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: return False else: @@ -231,11 +356,17 @@ def crypt_salt_is_valid(salt): 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}"} + #: valide sschemes that require no slat schemes_nosalt = {b"{MD5}", b"{SHA}", b"{SHA256}", b"{SHA384}", b"{SHA512}"} + #: map beetween scheme and hash function _schemes_to_hash = { b"{SMD5}": hashlib.md5, b"{MD5}": hashlib.md5, @@ -249,6 +380,7 @@ class LdapHashUserPassword(object): b"{SHA512}": hashlib.sha512 } + #: map between scheme and hash length _schemes_to_len = { b"{SMD5}": 16, b"{SSHA}": 20, @@ -258,7 +390,10 @@ class LdapHashUserPassword(object): } 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 class BadHash(ValueError): @@ -266,14 +401,19 @@ class LdapHashUserPassword(object): pass 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 @classmethod def _raise_bad_scheme(cls, scheme, valid, msg): """ - Raise BadScheme error for `scheme`, possible valid scheme are - in `valid`, the error message is `msg` + Raise :attr:`BadScheme` error for ``scheme``, possible valid scheme are + 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.sort() @@ -281,7 +421,12 @@ class LdapHashUserPassword(object): @classmethod 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: cls._raise_bad_scheme( scheme, @@ -291,7 +436,12 @@ class LdapHashUserPassword(object): @classmethod 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: cls._raise_bad_scheme( scheme, @@ -301,7 +451,12 @@ class LdapHashUserPassword(object): @classmethod 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: cls._raise_bad_scheme( scheme, @@ -312,8 +467,15 @@ class LdapHashUserPassword(object): @classmethod def hash(cls, scheme, password, salt=None, charset="utf8"): """ - Hash `password` with `scheme` using `salt`. - This three variable beeing encoded in `charset`. + Hash ``password`` with ``scheme`` using ``salt``. + 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() cls._test_scheme(scheme) @@ -339,7 +501,14 @@ class LdapHashUserPassword(object): @classmethod 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: raise cls.BadHash("%r should start with the scheme enclosed with { }" % hashed_passord) scheme = hashed_passord.split(b'}', 1)[0] @@ -348,7 +517,15 @@ class LdapHashUserPassword(object): @classmethod 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) cls._test_scheme(scheme) if scheme in cls.schemes_nosalt: @@ -364,8 +541,20 @@ class LdapHashUserPassword(object): def check_password(method, password, hashed_password, charset): """ - Check that `password` match `hashed_password` using `method`, - assuming the encoding is `charset`. + Check that ``password`` match `hashed_password` using ``method``, + 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): password = password.encode(charset) diff --git a/cas_server/views.py b/cas_server/views.py index 9d3fcc2..4ba32c4 100644 --- a/cas_server/views.py +++ b/cas_server/views.py @@ -10,7 +10,7 @@ # # (c) 2015-2016 Valentin Samir """views for the app""" -from .default_settings import settings +from .default_settings import settings, SessionStore from django.shortcuts import render, redirect from django.core.urlresolvers import reverse @@ -30,7 +30,6 @@ import pprint import requests from lxml import etree from datetime import timedelta -from importlib import import_module import cas_server.utils as utils import cas_server.forms as forms @@ -41,8 +40,6 @@ from .models import ServiceTicket, ProxyTicket, ProxyGrantingTicket from .models import ServicePattern, FederatedIendityProvider, FederatedUser from .federate import CASFederateValidateUser -SessionStore = import_module(settings.SESSION_ENGINE).SessionStore - logger = logging.getLogger(__name__) diff --git a/docs/Makefile b/docs/Makefile index 8df0199..f3190cc 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -50,6 +50,7 @@ clean: .PHONY: html html: + wget https://docs.djangoproject.com/en/1.9/_objects -O django.inv $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." diff --git a/docs/_ext/djangodocs.py b/docs/_ext/djangodocs.py new file mode 100644 index 0000000..0f29341 --- /dev/null +++ b/docs/_ext/djangodocs.py @@ -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('
%s
\n''' % (fname,)) + self.body.append(highlighted) + self.body.append('\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('\n') + + def visit_desc_parameterlist(self, node): + self.body.append('(') # by default sphinx puts 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('%s ' % title) + + def depart_versionmodified(self, node): + self.body.append("\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') diff --git a/docs/conf.py b/docs/conf.py index f8a40a0..900b2b0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,6 +20,7 @@ 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"))) SETUP = os.path.abspath('../setup.py') @@ -37,6 +38,7 @@ django.setup() # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ + 'djangodocs', 'sphinx.ext.autodoc', 'sphinx.ext.doctest', 'sphinx.ext.intersphinx', @@ -346,11 +348,11 @@ texinfo_documents = [ # texinfo_no_detailmenu = False # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'https://docs.python.org/': None} +intersphinx_mapping = { + "python": ('https://docs.python.org/', None), + "django": ('https://docs.djangoproject.com/en/1.9/', 'django.inv'), +} autodoc_member_order = 'bysource' -def setup(app): - from django_sphinx import process_docstring - # Register the docstring processor with sphinx - app.connect('autodoc-process-docstring', process_docstring) +locale_dirs = ['../test_venv/lib/python2.7/site-packages/django/conf/locale/'] diff --git a/docs/django_sphinx.py b/docs/django_sphinx.py deleted file mode 100644 index df5613d..0000000 --- a/docs/django_sphinx.py +++ /dev/null @@ -1,41 +0,0 @@ -import inspect -from django.utils.html import strip_tags -from django.utils.encoding import force_unicode - -def process_docstring(app, what, name, obj, options, lines): - # This causes import errors if left outside the function - from django.db import models - - # Only look at objects that inherit from Django's base model class - if inspect.isclass(obj) and issubclass(obj, models.Model): - # Grab the field list from the meta class - fields = obj._meta.fields - - for field in fields: - # Decode and strip any html out of the field's help text - help_text = strip_tags(force_unicode(field.help_text)) - - # Decode and capitalize the verbose name, for use if there isn't - # any help text - verbose_name = force_unicode(field.verbose_name).capitalize() - - if help_text: - # Add the model field to the end of the docstring as a param - # using the help text as the description - lines.append(u':param %s: %s' % (field.attname, help_text)) - else: - # Add the model field to the end of the docstring as a param - # using the verbose name as the description - lines.append(u':param %s: %s' % (field.attname, verbose_name)) - - # Add the field's type to the docstring - if isinstance(field, models.ForeignKey): - to = field.rel.to - lines.append(u':type %s: %s to :class:`~%s.%s`' % (field.attname, type(field).__name__, to.__module__, to.__name__)) - - else: - lines.append(u':type %s: %s' % (field.attname, type(field).__name__)) - - # Return the extended docstring - return lines - diff --git a/docs/package/cas_server.admin.rst b/docs/package/cas_server.admin.rst index 439fcea..8e79747 100644 --- a/docs/package/cas_server.admin.rst +++ b/docs/package/cas_server.admin.rst @@ -4,4 +4,4 @@ cas_server.admin module .. automodule:: cas_server.admin :members: :undoc-members: - :show-inheritance: + diff --git a/docs/package/cas_server.apps.rst b/docs/package/cas_server.apps.rst index 745ec67..cb9f7f6 100644 --- a/docs/package/cas_server.apps.rst +++ b/docs/package/cas_server.apps.rst @@ -4,4 +4,3 @@ cas_server.apps module .. automodule:: cas_server.apps :members: :undoc-members: - :show-inheritance: diff --git a/docs/package/cas_server.forms.rst b/docs/package/cas_server.forms.rst index 457ab51..f392d0e 100644 --- a/docs/package/cas_server.forms.rst +++ b/docs/package/cas_server.forms.rst @@ -3,5 +3,3 @@ cas_server.forms module .. automodule:: cas_server.forms :members: - :undoc-members: - :show-inheritance: diff --git a/docs/package/cas_server.models.rst b/docs/package/cas_server.models.rst index 8d11266..04adf35 100644 --- a/docs/package/cas_server.models.rst +++ b/docs/package/cas_server.models.rst @@ -4,4 +4,3 @@ cas_server.models module .. automodule:: cas_server.models :members: :undoc-members: - :show-inheritance: diff --git a/docs/package/cas_server.urls.rst b/docs/package/cas_server.urls.rst deleted file mode 100644 index 5127b5a..0000000 --- a/docs/package/cas_server.urls.rst +++ /dev/null @@ -1,7 +0,0 @@ -cas_server.urls module -====================== - -.. automodule:: cas_server.urls - :members: - :undoc-members: - :show-inheritance: From 8caf9156aafd9ca0831168d34470b141ac44881a Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Wed, 20 Jul 2016 18:29:34 +0200 Subject: [PATCH 07/26] Rename the unicode methode of ReturnUnicode in cas.py to prevent clash in spnhinx autodoc with unicode type --- cas_server/cas.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/cas_server/cas.py b/cas_server/cas.py index 9eec396..2c5178e 100644 --- a/cas_server/cas.py +++ b/cas_server/cas.py @@ -36,7 +36,7 @@ class CASError(ValueError): class ReturnUnicode(object): @staticmethod - def unicode(string, charset): + def u(string, charset): if not isinstance(string, six.text_type): return string.decode(charset) else: @@ -157,7 +157,7 @@ class CASClientV1(CASClientBase, ReturnUnicode): charset = content_type.split("charset=")[-1] else: charset = "ascii" - user = self.unicode(page.readline().strip(), charset) + user = self.u(page.readline().strip(), charset) return user, None, None else: return None, None, None @@ -202,18 +202,18 @@ class CASClientV2(CASClientBase, ReturnUnicode): def parse_attributes_xml_element(cls, element, charset): attributes = dict() for attribute in element: - tag = cls.self.unicode(attribute.tag, charset).split(u"}").pop() + tag = cls.self.u(attribute.tag, charset).split(u"}").pop() if tag in attributes: if isinstance(attributes[tag], list): - attributes[tag].append(cls.unicode(attribute.text, charset)) + attributes[tag].append(cls.u(attribute.text, charset)) else: attributes[tag] = [attributes[tag]] - attributes[tag].append(cls.unicode(attribute.text, charset)) + attributes[tag].append(cls.u(attribute.text, charset)) else: if tag == u'attraStyle': pass else: - attributes[tag] = cls.unicode(attribute.text, charset) + attributes[tag] = cls.u(attribute.text, charset) return attributes @classmethod @@ -238,9 +238,9 @@ class CASClientV2(CASClientBase, ReturnUnicode): if tree[0].tag.endswith('authenticationSuccess'): for element in tree[0]: if element.tag.endswith('user'): - user = cls.unicode(element.text, charset) + user = cls.u(element.text, charset) elif element.tag.endswith('proxyGrantingTicket'): - pgtiou = cls.unicode(element.text, charset) + pgtiou = cls.u(element.text, charset) elif element.tag.endswith('attributes'): attributes = cls.parse_attributes_xml_element(element, charset) return user, attributes, pgtiou @@ -255,15 +255,15 @@ class CASClientV3(CASClientV2, SingleLogoutMixin): def parse_attributes_xml_element(cls, element, charset): attributes = dict() for attribute in element: - tag = cls.unicode(attribute.tag, charset).split(u"}").pop() + tag = cls.u(attribute.tag, charset).split(u"}").pop() if tag in attributes: if isinstance(attributes[tag], list): - attributes[tag].append(cls.unicode(attribute.text, charset)) + attributes[tag].append(cls.u(attribute.text, charset)) else: attributes[tag] = [attributes[tag]] - attributes[tag].append(cls.unicode(attribute.text, charset)) + attributes[tag].append(cls.u(attribute.text, charset)) else: - attributes[tag] = cls.unicode(attribute.text, charset) + attributes[tag] = cls.u(attribute.text, charset) return attributes @classmethod @@ -323,25 +323,25 @@ class CASClientWithSAMLV1(CASClientV2, SingleLogoutMixin): # User is validated name_identifier = tree.find('.//' + SAML_1_0_ASSERTION_NS + 'NameIdentifier') if name_identifier is not None: - user = self.unicode(name_identifier.text, charset) + user = self.u(name_identifier.text, charset) attrs = tree.findall('.//' + SAML_1_0_ASSERTION_NS + 'Attribute') for at in attrs: if self.username_attribute in list(at.attrib.values()): - user = self.unicode( + user = self.u( at.find(SAML_1_0_ASSERTION_NS + 'AttributeValue').text, charset ) attributes[u'uid'] = user values = at.findall(SAML_1_0_ASSERTION_NS + 'AttributeValue') - key = self.unicode(at.attrib['AttributeName'], charset) + key = self.u(at.attrib['AttributeName'], charset) if len(values) > 1: values_array = [] for v in values: - values_array.append(self.unicode(v.text, charset)) + values_array.append(self.u(v.text, charset)) attributes[key] = values_array else: - attributes[key] = self.unicode(values[0].text, charset) + attributes[key] = self.u(values[0].text, charset) return user, attributes, None finally: page.close() From 25f305b538737ad1228ddbf0dcbf240be84fead7 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Wed, 20 Jul 2016 18:30:17 +0200 Subject: [PATCH 08/26] Reformat default_settings.py for documentation using sphinx autodoc --- cas_server/default_settings.py | 201 +++++++++++++++++++++------------ 1 file changed, 127 insertions(+), 74 deletions(-) diff --git a/cas_server/default_settings.py b/cas_server/default_settings.py index 0b24f62..e43cda1 100644 --- a/cas_server/default_settings.py +++ b/cas_server/default_settings.py @@ -13,84 +13,137 @@ from django.conf import settings from django.contrib.staticfiles.templatetags.staticfiles import static +from importlib import import_module -def setting_default(name, default_value): - """if the config `name` is not set, set it the `default_value`""" + +#: URL to the logo showed in the up left corner on the default templates. +CAS_LOGO_URL = static("cas_server/logo.png") +#: 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 + + +for name, default_value in globals().items(): + # get the current setting value, falling back to default_value value = getattr(settings, name, default_value) + # set the setting value to its value if defined, ellse to the default_value. setattr(settings, name, value) -setting_default('CAS_LOGO_URL', static("cas_server/logo.png")) - -setting_default('CAS_LOGIN_TEMPLATE', 'cas_server/login.html') -setting_default('CAS_WARN_TEMPLATE', 'cas_server/warn.html') -setting_default('CAS_LOGGED_TEMPLATE', 'cas_server/logged.html') -setting_default('CAS_LOGOUT_TEMPLATE', 'cas_server/logout.html') -setting_default('CAS_AUTH_CLASS', 'cas_server.auth.DjangoAuthUser') -# All CAS implementation MUST support ST and PT up to 32 chars, -# PGT and PGTIOU up to 64 chars and it is RECOMMENDED that all -# tickets up to 256 chars are supports so we use 64 for the default -# len. -setting_default('CAS_TICKET_LEN', 64) - -setting_default('CAS_LT_LEN', settings.CAS_TICKET_LEN) -setting_default('CAS_ST_LEN', settings.CAS_TICKET_LEN) -setting_default('CAS_PT_LEN', settings.CAS_TICKET_LEN) -setting_default('CAS_PGT_LEN', settings.CAS_TICKET_LEN) -setting_default('CAS_PGTIOU_LEN', settings.CAS_TICKET_LEN) - -setting_default('CAS_TICKET_VALIDITY', 60) -setting_default('CAS_PGT_VALIDITY', 3600) -setting_default('CAS_TICKET_TIMEOUT', 24*3600) -setting_default('CAS_PROXY_CA_CERTIFICATE_PATH', True) -setting_default('CAS_REDIRECT_TO_LOGIN_AFTER_LOGOUT', False) - -setting_default('CAS_AUTH_SHARED_SECRET', '') - -setting_default('CAS_LOGIN_TICKET_PREFIX', 'LT') -# Service tickets MUST begin with the characters ST so you should not change this -# Services MUST be able to accept service tickets of up to 32 characters in length -setting_default('CAS_SERVICE_TICKET_PREFIX', 'ST') -# Proxy tickets SHOULD begin with the characters, PT. -# Back-end services MUST be able to accept proxy tickets of up to 32 characters. -setting_default('CAS_PROXY_TICKET_PREFIX', 'PT') -# Proxy-granting tickets SHOULD begin with the characters PGT -# Services MUST be able to handle proxy-granting tickets of up to 64 -setting_default('CAS_PROXY_GRANTING_TICKET_PREFIX', 'PGT') -# Proxy-granting ticket IOUs SHOULD begin with the characters, PGTIOU -# Services MUST be able to handle PGTIOUs of up to 64 characters in length. -setting_default('CAS_PROXY_GRANTING_TICKET_IOU_PREFIX', 'PGTIOU') - -# Maximum number of parallel single log out requests send -# if more requests need to be send, there are queued -setting_default('CAS_SLO_MAX_PARALLEL_REQUESTS', 10) -# SLO request timeout. -setting_default('CAS_SLO_TIMEOUT', 5) - -setting_default('CAS_SQL_HOST', 'localhost') -setting_default('CAS_SQL_USERNAME', '') -setting_default('CAS_SQL_PASSWORD', '') -setting_default('CAS_SQL_DBNAME', '') -setting_default('CAS_SQL_DBCHARSET', 'utf8') -setting_default('CAS_SQL_USER_QUERY', 'SELECT user AS usersame, pass AS ' - 'password, users.* FROM users WHERE user = %s') -setting_default('CAS_SQL_PASSWORD_CHECK', 'crypt') # crypt or plain - -setting_default('CAS_TEST_USER', 'test') -setting_default('CAS_TEST_PASSWORD', 'test') -setting_default( - 'CAS_TEST_ATTRIBUTES', - { - 'nom': 'Nymous', - 'prenom': 'Ano', - 'email': 'anonymous@example.net', - 'alias': ['demo1', 'demo2'] - } -) - -setting_default('CAS_ENABLE_AJAX_AUTH', False) - -setting_default('CAS_FEDERATE', False) -setting_default('CAS_FEDERATE_REMEMBER_TIMEOUT', 604800) # one week +# if the federated mode is enabled, we must use the :class`cas_server.auth.CASFederateAuth` auth +# backend. if settings.CAS_FEDERATE: settings.CAS_AUTH_CLASS = "cas_server.auth.CASFederateAuth" + + +#: SessionStore class depending of :django:setting:`SESSION_ENGINE` +SessionStore = import_module(settings.SESSION_ENGINE).SessionStore From c8a22f6c92376004880d6b6ab429903e5098e667 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Wed, 20 Jul 2016 18:30:36 +0200 Subject: [PATCH 09/26] Remove tests unused variable --- cas_server/tests/test_view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cas_server/tests/test_view.py b/cas_server/tests/test_view.py index bfa4c32..2969801 100644 --- a/cas_server/tests/test_view.py +++ b/cas_server/tests/test_view.py @@ -993,7 +993,7 @@ class ValidateTestCase(TestCase): def test_validate_service_renew(self): """test with a valid (ticket, service) asking for auth renewal""" # 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) ticket_value = response['Location'].split('ticket=')[-1] # get a bare client From 66b9daff688d344018e827cb251b9b118a919d3a Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Wed, 20 Jul 2016 18:40:46 +0200 Subject: [PATCH 10/26] Update default_settings.py for python3 In python 3 dict.items do not return a copied list so we force a copy to avoid changing the global dict while iterating over it --- cas_server/default_settings.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cas_server/default_settings.py b/cas_server/default_settings.py index e43cda1..024d59e 100644 --- a/cas_server/default_settings.py +++ b/cas_server/default_settings.py @@ -104,8 +104,9 @@ CAS_SQL_DBNAME = '' 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``. +#: 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 @@ -131,8 +132,8 @@ CAS_FEDERATE = False #: Time after witch the cookie use for “remember my identity provider” expire (one week). CAS_FEDERATE_REMEMBER_TIMEOUT = 604800 - -for name, default_value in globals().items(): +current_global = globals().copy() +for name, default_value in current_global.items(): # get the current setting value, falling back to default_value value = getattr(settings, name, default_value) # set the setting value to its value if defined, ellse to the default_value. From 004e76f29e50f99a3783bb15a1e7bfe2c94a71a4 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Wed, 20 Jul 2016 18:41:52 +0200 Subject: [PATCH 11/26] Remove tests unused variable --- cas_server/tests/test_view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cas_server/tests/test_view.py b/cas_server/tests/test_view.py index 2969801..fef7ec3 100644 --- a/cas_server/tests/test_view.py +++ b/cas_server/tests/test_view.py @@ -1193,7 +1193,7 @@ class ValidateServiceTestCase(TestCase, XmlContent): def test_validate_service_renew(self): """test with a valid (ticket, service) asking for auth renewal""" # 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) ticket_value = response['Location'].split('ticket=')[-1] # get a bare client From 8a4a4a833f709aaba3d79445c83c0888a3715be7 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Wed, 20 Jul 2016 18:46:33 +0200 Subject: [PATCH 12/26] Codacy warning about constant names --- cas_server/default_settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cas_server/default_settings.py b/cas_server/default_settings.py index 024d59e..3c08af3 100644 --- a/cas_server/default_settings.py +++ b/cas_server/default_settings.py @@ -132,8 +132,8 @@ CAS_FEDERATE = False #: Time after witch the cookie use for “remember my identity provider” expire (one week). CAS_FEDERATE_REMEMBER_TIMEOUT = 604800 -current_global = globals().copy() -for name, default_value in current_global.items(): +GLOBALS = globals().copy() +for name, default_value in GLOBALS.items(): # get the current setting value, falling back to default_value value = getattr(settings, name, default_value) # set the setting value to its value if defined, ellse to the default_value. From d812257daa51945b8bdfef9d33530cb3175eb063 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Wed, 20 Jul 2016 18:52:11 +0200 Subject: [PATCH 13/26] Exclude a should not happen branche from coverage in auth.TestAuthUser.attributs --- cas_server/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cas_server/auth.py b/cas_server/auth.py index 2cb0880..31aa4f2 100644 --- a/cas_server/auth.py +++ b/cas_server/auth.py @@ -112,7 +112,7 @@ class TestAuthUser(AuthUser): """ if self.username == settings.CAS_TEST_USER: return settings.CAS_TEST_ATTRIBUTES - else: + else: # pragma: no cover (should not happen) return {} From 542a0351e81d67315abb0c390ddfe685f2210d6d Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Fri, 22 Jul 2016 14:47:31 +0200 Subject: [PATCH 14/26] Use https://badges.genua.fr for badges Our own instance of https://shields.io/ --- README.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 9c3939d..151c32f 100644 --- a/README.rst +++ b/README.rst @@ -1,19 +1,19 @@ CAS Server ########## -.. image:: https://travis-ci.org/nitmir/django-cas-server.svg?branch=master +.. image:: https://badges.genua.fr/travis/nitmir/django-cas-server/master.svg :target: https://travis-ci.org/nitmir/django-cas-server -.. image:: https://img.shields.io/pypi/v/django-cas-server.svg +.. image:: https://badges.genua.fr/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 +.. image:: https://badges.genua.fr/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 +.. image:: https://badges.genua.fr/codacy/grade/255c21623d6946ef8802fa7995b61366/master.svg :target: https://www.codacy.com/app/valentin-samir/django-cas-server -.. image:: https://api.codacy.com/project/badge/Coverage/255c21623d6946ef8802fa7995b61366 +.. image:: https://badges.genua.fr/codacy/coverage/255c21623d6946ef8802fa7995b61366/master.svg :target: https://www.codacy.com/app/valentin-samir/django-cas-server CAS Server is a Django application implementing the `CAS Protocol 3.0 Specification From 4127af0db1f505afda3aa91bca590901342de0a1 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sat, 23 Jul 2016 18:41:46 +0200 Subject: [PATCH 15/26] Update doc --- Makefile | 6 +- cas_server/federate.py | 4 + cas_server/views.py | 425 ++++++++++++++++++++++++++++++++++------- docs/Makefile | 1 - docs/conf.py | 15 +- setup.py | 145 +++++++------- 6 files changed, 458 insertions(+), 138 deletions(-) diff --git a/Makefile b/Makefile index a83a1e4..a186ddc 100644 --- a/Makefile +++ b/Makefile @@ -68,9 +68,5 @@ run_tests: test_venv test_venv/bin/sphinx-build: test_venv test_venv/bin/pip install Sphinx sphinx_rtd_theme - -docs/package: test_venv/bin/sphinx-build - test_venv/bin/sphinx-apidoc -f -e cas_server -o docs/package/ cas_server/migrations/ cas_server/management/ cas_server/tests/ #cas_server/cas.py - -docs: docs/package test_venv/bin/sphinx-build +docs: test_venv/bin/sphinx-build bash -c "source test_venv/bin/activate; cd docs; make html" diff --git a/cas_server/federate.py b/cas_server/federate.py index 95ef3c7..156d3ea 100644 --- a/cas_server/federate.py +++ b/cas_server/federate.py @@ -37,6 +37,10 @@ class CASFederateValidateUser(object): attributs = {} #: the CAS client instance client = None + #: the provider returned username this the provider suffix appended + federated_username = None + #: the identity provider + provider = None def __init__(self, provider, service_url): self.provider = provider diff --git a/cas_server/views.py b/cas_server/views.py index 4ba32c4..2bc3b51 100644 --- a/cas_server/views.py +++ b/cas_server/views.py @@ -63,8 +63,17 @@ class AttributesMixin(object): class LogoutMixin(object): """destroy CAS session utils""" def logout(self, all_session=False): - """effectively destroy CAS session""" + """ + effectively destroy a CAS session + + :param boolean all_session: If ``True`` destroy all the user sessions, otherwise + destroy the current user session. + :return: The number of destroyed sessions + :rtype: int + """ + # initialize the counter of the number of destroyed sesisons session_nb = 0 + # save the current user username before flushing the session username = self.request.session.get("username") if username: if all_session: @@ -77,14 +86,13 @@ class LogoutMixin(object): username=username, session_key=self.request.session.session_key ) - if settings.CAS_FEDERATE: - models.FederateSLO.objects.filter( - username=username, - session_key=self.request.session.session_key - ).delete() + # flush the session self.request.session.flush() + # send SLO requests user.logout(self.request) + # delete the user user.delete() + # increment the destroyed session counter session_nb += 1 except models.User.DoesNotExist: # if user not found in database, flush the session anyway @@ -92,32 +100,55 @@ class LogoutMixin(object): # If all_session is set logout user from alternative sessions if all_session: + # Iterate over all user sessions for user in models.User.objects.filter(username=username): + # get the user session session = SessionStore(session_key=user.session_key) + # flush the session session.flush() + # send SLO requests user.logout(self.request) + # delete the user user.delete() + # increment the destroyed session counter session_nb += 1 - logger.info("User %s logged out" % username) + if username: + logger.info("User %s logged out" % username) return session_nb class LogoutView(View, LogoutMixin): """destroy CAS session (logout) view""" + #: current :class:`django.http.HttpRequest` object request = None + #: service GET parameter service = None + #: url GET paramet + url = None + #: ``True`` if the HTTP_X_AJAX http header is sent and ``settings.CAS_ENABLE_AJAX_AUTH`` + #: is ``True``, ``False`` otherwise. + ajax = None def init_get(self, request): - """Initialize GET received parameters""" + """ + Initialize the :class:`LogoutView` attributes on GET request + + :param django.http.HttpRequest request: The current request object + """ self.request = request self.service = request.GET.get('service') self.url = request.GET.get('url') self.ajax = settings.CAS_ENABLE_AJAX_AUTH and 'HTTP_X_AJAX' in request.META def get(self, request, *args, **kwargs): - """methode called on GET request on this view""" + """ + methode called on GET request on this view + + :param django.http.HttpRequest request: The current request object + """ logger.info("logout requested") + # initialize the class attributes self.init_get(request) # if CAS federation mode is enable, bakup the provider before flushing the sessions if settings.CAS_FEDERATE: @@ -129,7 +160,8 @@ class LogoutView(View, LogoutMixin): except FederatedUser.DoesNotExist: auth = None session_nb = self.logout(self.request.GET.get("all")) - # if CAS federation mode is enable, redirect to user CAS logout page + # if CAS federation mode is enable, redirect to user CAS logout page, appending the + # current querystring if settings.CAS_FEDERATE: if auth is not None: params = utils.copy_params(request.GET) @@ -139,11 +171,12 @@ class LogoutView(View, LogoutMixin): if self.service: list(messages.get_messages(request)) # clean messages before leaving the django app return HttpResponseRedirect(self.service) + # if service is not set but url is set, redirect to url after logout elif self.url: list(messages.get_messages(request)) # clean messages before leaving the django app return HttpResponseRedirect(self.url) - # else redirect to login page else: + # build logout message depending of the number of sessions the user logs out if session_nb == 1: logout_msg = _( "

Logout successful

" @@ -164,6 +197,8 @@ class LogoutView(View, LogoutMixin): "For security reasons, exit your web browser." ) + # depending of settings, redirect to the login page with a logout message or display + # the logout page. The default is to display tge logout page. if settings.CAS_REDIRECT_TO_LOGIN_AFTER_LOGOUT: messages.add_message(request, messages.SUCCESS, logout_msg) if self.ajax: @@ -191,23 +226,42 @@ class LogoutView(View, LogoutMixin): class FederateAuth(View): """view to authenticated user agains a backend CAS then CAS_FEDERATE is True""" - @method_decorator(csrf_exempt) + @method_decorator(csrf_exempt) # csrf is disabled for allowing SLO requests reception def dispatch(self, request, *args, **kwargs): - """dispatch different http request to the methods of the same name""" + """ + dispatch different http request to the methods of the same name + + :param django.http.HttpRequest request: The current request object + """ return super(FederateAuth, self).dispatch(request, *args, **kwargs) @staticmethod def get_cas_client(request, provider): - """return a CAS client object matching provider""" + """ + return a CAS client object matching provider + + :param django.http.HttpRequest request: The current request object + :param cas_server.models.FederatedIendityProvider provider: the user identity provider + :return: The user CAS client object + :rtype: :class:`federate.CASFederateValidateUser` + """ + # compute the current url, ignoring ticket dans provider GET parameters service_url = utils.get_current_url(request, {"ticket", "provider"}) return CASFederateValidateUser(provider, service_url) def post(self, request, provider=None): - """method called on POST request""" + """ + method called on POST request + + :param django.http.HttpRequest request: The current request object + :param unicode provider: Optional parameter. The user provider suffix. + """ + # if settings.CAS_FEDERATE is not True redirect to the login page if not settings.CAS_FEDERATE: logger.warning("CAS_FEDERATE is False, set it to True to use the federated mode") return redirect("cas_server:login") - # POST with a provider, this is probably an SLO request + # POST with a provider suffix, this is probably an SLO request. csrf is disabled for + # allowing SLO requests reception try: provider = FederatedIendityProvider.objects.get(suffix=provider) auth = self.get_cas_client(request, provider) @@ -234,6 +288,7 @@ class FederateAuth(View): params=params ) response = HttpResponseRedirect(url) + # If the user has checked "remember my identity provider" store it in a cookie if form.cleaned_data["remember"]: max_age = settings.CAS_FEDERATE_REMEMBER_TIMEOUT utils.set_cookie( @@ -247,21 +302,33 @@ class FederateAuth(View): return redirect("cas_server:login") def get(self, request, provider=None): - """method called on GET request""" + """ + method called on GET request + + :param django.http.HttpRequest request: The current request object + :param unicode provider: Optional parameter. The user provider suffix. + """ + # if settings.CAS_FEDERATE is not True redirect to the login page if not settings.CAS_FEDERATE: logger.warning("CAS_FEDERATE is False, set it to True to use the federated mode") return redirect("cas_server:login") + # Is the user is already authenticated, no need to request authentication to the user + # identity provider. if self.request.session.get("authenticated"): logger.warning("User already authenticated, dropping federate authentication request") return redirect("cas_server:login") try: + # get the identity provider from its suffix provider = FederatedIendityProvider.objects.get(suffix=provider) + # get a CAS client for the user identity provider auth = self.get_cas_client(request, provider) + # if no ticket submited, redirect to the identity provider CAS login page if 'ticket' not in request.GET: logger.info("Trying to authenticate again %s" % auth.provider.server_url) return HttpResponseRedirect(auth.get_login_url()) else: ticket = request.GET['ticket'] + # if the ticket validation succeed if auth.verify_ticket(ticket): logger.info( "Got a valid ticket for %s from %s" % ( @@ -273,8 +340,11 @@ class FederateAuth(View): request.session["federate_username"] = auth.federated_username request.session["federate_ticket"] = ticket auth.register_slo(auth.federated_username, request.session.session_key, ticket) + # redirect to the the login page for the user to become authenticated + # thanks to the `federate_username` and `federate_ticket` session parameters url = utils.reverse_params("cas_server:login", params) return HttpResponseRedirect(url) + # else redirect to the identity provider CAS login page else: logger.info( "Got a invalid ticket for %s from %s. Retrying to authenticate" % ( @@ -285,6 +355,7 @@ class FederateAuth(View): return HttpResponseRedirect(auth.get_login_url()) except FederatedIendityProvider.DoesNotExist: logger.warning("Identity provider suffix %s not found" % provider) + # if the identity provider is not found, redirect to the login page return redirect("cas_server:login") @@ -294,21 +365,38 @@ class LoginView(View, LogoutMixin): # pylint: disable=too-many-instance-attributes # Nine is reasonable in this case. + #: The current :class:`models.User` object user = None + #: The form to display to the user form = None + #: current :class:`django.http.HttpRequest` object request = None + #: service GET/POST parameter service = None + #: ``True`` if renew GET/POST parameter is present and not "False" renew = None + #: the warn GET/POST parameter + warn = None + #: the gateway GET/POST parameter gateway = None + #: the method GET/POST parameter method = None + + #: ``True`` if the HTTP_X_AJAX http header is sent and ``settings.CAS_ENABLE_AJAX_AUTH`` + #: is ``True``, ``False`` otherwise. ajax = None + #: ``True`` if the user has just authenticated renewed = False + #: ``True`` if renew GET/POST parameter is present and not "False" warned = False - # used if CAS_FEDERATE is True + #: The :class:`FederateAuth` transmited username (only used if ``settings.CAS_FEDERATE`` + #: is ``True``) username = None + #: The :class:`FederateAuth` transmited ticket (only used if ``settings.CAS_FEDERATE`` is + #: ``True``) ticket = None INVALID_LOGIN_TICKET = 1 @@ -319,7 +407,11 @@ class LoginView(View, LogoutMixin): USER_NOT_AUTHENTICATED = 6 def init_post(self, request): - """Initialize POST received parameters""" + """ + Initialize POST received parameters + + :param django.http.HttpRequest request: The current request object + """ self.request = request self.service = request.POST.get('service') self.renew = bool(request.POST.get('renew') and request.POST['renew'] != "False") @@ -340,7 +432,12 @@ class LoginView(View, LogoutMixin): self.request.session['lt'] = self.request.session['lt'][-100:] def check_lt(self): - """Check is the POSTed LoginTicket is valid, if yes invalide it""" + """ + Check is the POSTed LoginTicket is valid, if yes invalide it + + :return: ``True`` if the LoginTicket is valid, ``False`` otherwise + :rtype: bool + """ # save LT for later check lt_valid = self.request.session.get('lt', []) lt_send = self.request.POST.get('lt') @@ -351,12 +448,20 @@ class LoginView(View, LogoutMixin): return False else: self.request.session['lt'].remove(lt_send) + # we need to redo the affectation for django to detect that the list has changed + # and for its new value to be store in the session self.request.session['lt'] = self.request.session['lt'] return True def post(self, request, *args, **kwargs): - """methode called on POST request on this view""" + """ + methode called on POST request on this view + + :param django.http.HttpRequest request: The current request object + """ + # initialize class parameters self.init_post(request) + # process the POST request ret = self.process_post() if ret == self.INVALID_LOGIN_TICKET: messages.add_message( @@ -365,6 +470,8 @@ class LoginView(View, LogoutMixin): _(u"Invalid login ticket") ) elif ret == self.USER_LOGIN_OK: + # On successful login, update the :class:`models.User` ``date`` + # attribute by saving it. (``auto_now=True``) self.user = models.User.objects.get_or_create( username=self.request.session['username'], session_key=self.request.session.session_key @@ -375,18 +482,31 @@ class LoginView(View, LogoutMixin): self.ticket = None self.username = None self.init_form() + # On login failure, flush the session self.logout() elif ret == self.USER_ALREADY_LOGGED: pass - else: - raise EnvironmentError("invalid output for LoginView.process_post") # pragma: no cover + else: # pragma: no cover (should no happen) + raise EnvironmentError("invalid output for LoginView.process_post") + # call the GET/POST common part return self.common() def process_post(self): """ Analyse the POST request: + * check that the LoginTicket is valid * check that the user sumited credentials are valid + + :return: + * :attr:`INVALID_LOGIN_TICKET` if the POSTed LoginTicket is not valid + * :attr:`USER_ALREADY_LOGGED` if the user is already logged and do no request + reauthentication. + * :attr:`USER_LOGIN_FAILURE` if the user is not logged or request for + reauthentication and his credentials are not valid + * :attr:`USER_LOGIN_OK` if the user is not logged or request for + reauthentication and his credentials are valid + :rtype: int """ if not self.check_lt(): values = self.request.POST.copy() @@ -396,6 +516,7 @@ class LoginView(View, LogoutMixin): logger.warning("Receive an invalid login ticket") return self.INVALID_LOGIN_TICKET elif not self.request.session.get("authenticated") or self.renew: + # authentication request receive, initialize the form to use self.init_form(self.request.POST) if self.form.is_valid(): self.request.session.set_expiry(0) @@ -414,7 +535,11 @@ class LoginView(View, LogoutMixin): return self.USER_ALREADY_LOGGED def init_get(self, request): - """Initialize GET received parameters""" + """ + Initialize GET received parameters + + :param django.http.HttpRequest request: The current request object + """ self.request = request self.service = request.GET.get('service') self.renew = bool(request.GET.get('renew') and request.GET['renew'] != "False") @@ -423,6 +548,8 @@ class LoginView(View, LogoutMixin): self.ajax = settings.CAS_ENABLE_AJAX_AUTH and 'HTTP_X_AJAX' in request.META self.warn = request.GET.get('warn') if settings.CAS_FEDERATE: + # here username and ticket are fetch from the session after a redirection from + # FederateAuth.get self.username = request.session.get("federate_username") self.ticket = request.session.get("federate_ticket") if self.username: @@ -431,22 +558,43 @@ class LoginView(View, LogoutMixin): del request.session["federate_ticket"] def get(self, request, *args, **kwargs): - """methode called on GET request on this view""" + """ + methode called on GET request on this view + + :param django.http.HttpRequest request: The current request object + """ + # initialize class parameters self.init_get(request) + # process the GET request self.process_get() + # call the GET/POST common part return self.common() def process_get(self): - """Analyse the GET request""" + """ + Analyse the GET request + + :return: + * :attr:`USER_NOT_AUTHENTICATED` if the user is not authenticated or is requesting + for authentication renewal + * :attr:`USER_AUTHENTICATED` if the user is authenticated and is not requesting + for authentication renewal + :rtype: int + """ # generate a new LT self.gen_lt() if not self.request.session.get("authenticated") or self.renew: + # authentication will be needed, initialize the form to use self.init_form() return self.USER_NOT_AUTHENTICATED return self.USER_AUTHENTICATED def init_form(self, values=None): - """Initialization of the good form depending of POST and GET parameters""" + """ + Initialization of the good form depending of POST and GET parameters + + :param django.http.QueryDict values: A POST or GET QueryDict + """ form_initial = { 'service': self.service, 'method': self.method, @@ -472,7 +620,19 @@ class LoginView(View, LogoutMixin): ) def service_login(self): - """Perform login agains a service""" + """ + Perform login agains a service + + :return: + * The rendering of the ``settings.CAS_WARN_TEMPLATE`` if the user asked to be + warned before ticket emission and has not yep been warned. + * The redirection to the service URL with a ticket GET parameter + * The redirection to the service URL without a ticket if ticket generation failed + and the :attr:`gateway` attribute is set + * The rendering of the ``settings.CAS_LOGGED_TEMPLATE`` template with some error + messages if the ticket generation failed (e.g: user not allowed). + :rtype: django.http.HttpResponse + """ try: # is the service allowed service_pattern = ServicePattern.validate(self.service) @@ -562,12 +722,22 @@ class LoginView(View, LogoutMixin): return json_response(self.request, data) def authenticated(self): - """Processing authenticated users""" + """ + Processing authenticated users + + :return: + * The returned value of :meth:`service_login` if :attr:`service` is defined + * The rendering of ``settings.CAS_LOGGED_TEMPLATE`` otherwise + :rtype: django.http.HttpResponse + """ + # Try to get the current :class:`models.User` object for the current + # session try: self.user = models.User.objects.get( username=self.request.session.get("username"), session_key=self.request.session.session_key ) + # if not found, flush the session and redirect to the login page except models.User.DoesNotExist: logger.warning( "User %s seems authenticated but is not found in the database." % ( @@ -585,9 +755,10 @@ class LoginView(View, LogoutMixin): else: return utils.redirect_params("cas_server:login", params=self.request.GET) - # if login agains a service is self.requestest + # if login agains a service if self.service: return self.service_login() + # else display the logged template else: if self.ajax: data = {"status": "success", "detail": "logged"} @@ -600,7 +771,16 @@ class LoginView(View, LogoutMixin): ) def not_authenticated(self): - """Processing non authenticated users""" + """ + Processing non authenticated users + + :return: + * The rendering of ``settings.CAS_LOGIN_TEMPLATE`` with various messages + depending of GET/POST parameters + * The redirection to :class:`FederateAuth` if ``settings.CAS_FEDERATE`` is ``True`` + and the "remember my identity provider" cookie is found + :rtype: django.http.HttpResponse + """ if self.service: try: service_pattern = ServicePattern.validate(self.service) @@ -678,7 +858,15 @@ class LoginView(View, LogoutMixin): ) def common(self): - """Part execute uppon GET and POST request""" + """ + Common part execute uppon GET and POST request + + :return: + * The returned value of :meth:`authenticated` if the user is authenticated and + not requesting for authentication or if the authentication has just been renewed + * The returned value of :meth:`not_authenticated` otherwise + :rtype: django.http.HttpResponse + """ # if authenticated and successfully renewed authentication if needed if self.request.session.get("authenticated") and (not self.renew or self.renewed): return self.authenticated() @@ -688,14 +876,29 @@ class LoginView(View, LogoutMixin): class Auth(View): """A simple view to validate username/password/service tuple""" + # csrf is disable as it is intended to be used by programs. Security is assured by a shared + # secret between the programs dans django-cas-server. @method_decorator(csrf_exempt) def dispatch(self, request, *args, **kwargs): - """dispatch requests based on method GET, POST, ...""" + """ + dispatch requests based on method GET, POST, ... + + :param django.http.HttpRequest request: The current request object + """ return super(Auth, self).dispatch(request, *args, **kwargs) @staticmethod def post(request): - """methode called on GET request on this view""" + """ + methode called on POST request on this view + + :param django.http.HttpRequest request: The current request object + :return: ``HttpResponse(u"yes\\n")`` if the POSTed tuple (username, password, service) + if valid (i.e. (username, password) is valid dans username is allowed on service). + ``HttpResponse(u"no\\n…")`` otherwise, with possibly an error message on the second + line. + :rtype: django.http.HttpResponse + """ username = request.POST.get('username') password = request.POST.get('password') service = request.POST.get('service') @@ -742,10 +945,20 @@ class Validate(View): """service ticket validation""" @staticmethod def get(request): - """methode called on GET request on this view""" + """ + methode called on GET request on this view + + :param django.http.HttpRequest request: The current request object + :return: + * ``HttpResponse("yes\\nusername")`` if submited (service, ticket) is valid + * else ``HttpResponse("no\\n")`` + :rtype: django.http.HttpResponse + """ + # store wanted GET parameters service = request.GET.get('service') ticket = request.GET.get('ticket') renew = True if request.GET.get('renew') else False + # service and ticket parameters are mandatory if service and ticket: try: ticket_queryset = ServiceTicket.objects.filter( @@ -801,6 +1014,10 @@ class Validate(View): @python_2_unicode_compatible class ValidateError(Exception): """handle service validation error""" + #: The error code + code = None + #: The error message + msg = None def __init__(self, code, msg=""): self.code = code self.msg = msg @@ -810,7 +1027,13 @@ class ValidateError(Exception): return u"%s" % self.msg def render(self, request): - """render the error template for the exception""" + """ + render the error template for the exception + + :param django.http.HttpRequest request: The current request object: + :return: the rendered ``cas_server/serviceValidateError.xml`` template + :rtype: django.http.HttpResponse + """ return render( request, "cas_server/serviceValidateError.xml", @@ -819,23 +1042,39 @@ class ValidateError(Exception): ) -class ValidateService(View, AttributesMixin): +class ValidateService(View): """service ticket validation [CAS 2.0] and [CAS 3.0]""" + #: Current :class:`django.http.HttpRequest` object request = None + #: The service GET parameter service = None + #: the ticket GET parameter ticket = None + #: the pgtUrl GET parameter pgt_url = None + #: the renew GET parameter renew = None + #: specify if ProxyTicket are allowed by the view. Hence we user the same view for + #: ``/serviceValidate`` and ``/proxyValidate`` juste changing the parameter. allow_proxy_ticket = False def get(self, request): - """methode called on GET request on this view""" + """ + methode called on GET request on this view + + :param django.http.HttpRequest request: The current request object: + :return: The rendering of ``cas_server/serviceValidate.xml`` if no errors is raised, + the rendering or ``cas_server/serviceValidateError.xml`` otherwise. + :rtype: django.http.HttpResponse + """ + # define the class parameters self.request = request self.service = request.GET.get('service') self.ticket = request.GET.get('ticket') self.pgt_url = request.GET.get('pgtUrl') self.renew = True if request.GET.get('renew') else False + # service and ticket parameter are mandatory if not self.service or not self.ticket: logger.warning("ValidateService: missing ticket or service") return ValidateError( @@ -844,7 +1083,9 @@ class ValidateService(View, AttributesMixin): ).render(request) else: try: + # search the ticket in the database self.ticket, proxies = self.process_ticket() + # prepare template rendering context params = { 'username': self.ticket.user.username, 'attributes': self.attributes(), @@ -890,35 +1131,42 @@ class ValidateService(View, AttributesMixin): return error.render(request) def process_ticket(self): - """fetch the ticket angains the database and check its validity""" + """ + fetch the ticket against the database and check its validity + + :raises ValidateError: if the ticket is not found or not valid, potentially for that + service + :returns: A couple (ticket, proxies list) + :rtype: :obj:`tuple` + """ try: proxies = [] - ticket_class = models.Ticket.get_class(self.ticket) - if ticket_class: - ticket_queryset = ticket_class.objects.filter( - value=self.ticket, - validate=False, - creation__gt=(timezone.now() - timedelta(seconds=ServiceTicket.VALIDITY)) - ) - if self.renew: - ticket = ticket_queryset.get(renew=True) - else: - ticket = ticket_queryset.get() - if ticket_class == models.ProxyTicket: - for prox in ticket.proxies.all(): - proxies.append(prox.url) + if self.allow_proxy_ticket: + ticket = models.Ticket.get(self.ticket, self.renew) else: - raise ValidateError(u'INVALID_TICKET', self.ticket) - ticket.validate = True - ticket.save() + ticket = models.ServiceTicket.get(self.ticket, self.renew) + try: + for prox in ticket.proxies.all(): + proxies.append(prox.url) + except AttributeError: + pass if ticket.service != self.service: raise ValidateError(u'INVALID_SERVICE', self.service) return ticket, proxies + except Ticket.DoesNotExist: + raise ValidateError(u'INVALID_TICKET', self.ticket) except (ServiceTicket.DoesNotExist, ProxyTicket.DoesNotExist): raise ValidateError(u'INVALID_TICKET', 'ticket not found') def process_pgturl(self, params): - """Handle PGT request""" + """ + Handle PGT request + + :param dict params: A template context dict + :raises ValidateError: if pgtUrl is invalid or if TLS validation of the pgtUrl fails + :return: The rendering of ``cas_server/serviceValidate.xml``, using ``params`` + :rtype: django.http.HttpResponse + """ try: pattern = ServicePattern.validate(self.pgt_url) if pattern.proxy_callback: @@ -979,16 +1227,27 @@ class ValidateService(View, AttributesMixin): class Proxy(View): """proxy ticket service""" + #: Current :class:`django.http.HttpRequest` object request = None + #: A ProxyGrantingTicket from the pgt GET parameter pgt = None + #: the targetService GET parameter target_service = None def get(self, request): - """methode called on GET request on this view""" + """ + methode called on GET request on this view + + :param django.http.HttpRequest request: The current request object: + :return: The returned value of :meth:`process_proxy` if no error is raised, + else the rendering of ``cas_server/serviceValidateError.xml``. + :rtype: django.http.HttpResponse + """ self.request = request self.pgt = request.GET.get('pgt') self.target_service = request.GET.get('targetService') try: + # pgt and targetService parameters are mandatory if self.pgt and self.target_service: return self.process_proxy() else: @@ -1001,10 +1260,18 @@ class Proxy(View): return error.render(request) def process_proxy(self): - """handle PT request""" + """ + handle PT request + + :raises ValidateError: if the PGT is not found, or the target service not allowed or + the user not allowed on the tardet service. + :return: The rendering of ``cas_server/proxy.xml`` + :rtype: django.http.HttpResponse + """ try: # is the target service allowed pattern = ServicePattern.validate(self.target_service) + # to get a proxy ticket require that the service allow it if not pattern.proxy: raise ValidateError( u'UNAUTHORIZED_SERVICE', @@ -1022,7 +1289,8 @@ class Proxy(View): ProxyTicket, self.target_service, pattern, - renew=False) + renew=False + ) models.Proxy.objects.create(proxy_ticket=pticket, url=ticket.service) logger.info( "Proxy ticket created for user %s on service %s." % ( @@ -1050,6 +1318,10 @@ class Proxy(View): @python_2_unicode_compatible class SamlValidateError(Exception): """handle saml validation error""" + #: The error code + code = None + #: The error message + msg = None def __init__(self, code, msg=""): self.code = code self.msg = msg @@ -1059,7 +1331,13 @@ class SamlValidateError(Exception): return u"%s" % self.msg def render(self, request): - """render the error template for the exception""" + """ + render the error template for the exception + + :param django.http.HttpRequest request: The current request object: + :return: the rendered ``cas_server/samlValidateError.xml`` template + :rtype: django.http.HttpResponse + """ return render( request, "cas_server/samlValidateError.xml", @@ -1082,11 +1360,23 @@ class SamlValidate(View, AttributesMixin): @method_decorator(csrf_exempt) def dispatch(self, request, *args, **kwargs): - """dispatch requests based on method GET, POST, ...""" + """ + dispatch requests based on method GET, POST, ... + + :param django.http.HttpRequest request: The current request object + """ return super(SamlValidate, self).dispatch(request, *args, **kwargs) def post(self, request): - """methode called on POST request on this view""" + """ + methode called on POST request on this view + + :param django.http.HttpRequest request: The current request object + :return: the rendering of ``cas_server/samlValidate.xml`` if no error is raised, + else the rendering of ``cas_server/samlValidateError.xml``. + :rtype: django.http.HttpResponse + + """ self.request = request self.target = request.GET.get('TARGET') self.root = etree.fromstring(request.body) @@ -1134,7 +1424,14 @@ class SamlValidate(View, AttributesMixin): return error.render(request) def process_ticket(self): - """validate ticket from SAML XML body""" + """ + validate ticket from SAML XML body + + :raises: SamlValidateError: if the ticket is not found or not valid, or if we fail + to parse the posted XML. + :return: a ticket object + :rtype: :class:`models.Ticket` + """ try: auth_req = self.root.getchildren()[1].getchildren()[0] ticket = auth_req.getchildren()[0].text diff --git a/docs/Makefile b/docs/Makefile index f3190cc..8df0199 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -50,7 +50,6 @@ clean: .PHONY: html html: - wget https://docs.djangoproject.com/en/1.9/_objects -O django.inv $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." diff --git a/docs/conf.py b/docs/conf.py index 900b2b0..a50963b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,7 +22,7 @@ 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"))) -SETUP = os.path.abspath('../setup.py') +import setup as mysetup os.environ['DJANGO_SETTINGS_MODULE'] = 'cas_server.tests.settings' @@ -72,7 +72,7 @@ author = u'Valentin Samir' # built documents. # # The short X.Y version. -version = os.popen("python %s -V" % SETUP).read().strip().decode("ascii") +version = mysetup.VERSION # The full version, including alpha/beta/rc tags. release = version @@ -356,3 +356,14 @@ intersphinx_mapping = { 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() diff --git a/setup.py b/setup.py index 85a8838..1b1b180 100644 --- a/setup.py +++ b/setup.py @@ -2,78 +2,91 @@ import os import pkg_resources from setuptools import setup +VERSION = '0.6.0' + with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as readme: README = readme.read() -# allow setup.py to be run from any path -os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) +if __name__ == '__main__': + # allow setup.py to be run from any path + os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) -# if we have Django 1.8 available, use last version of django-boostrap3 -try: - pkg_resources.require('Django >= 1.8') - django_bootstrap3 = 'django-bootstrap3 >= 5.4' - django = 'Django >= 1.8,<1.10' -except pkg_resources.VersionConflict: - # Else if we have django 1.7, we need django-boostrap3 < 7.0.0 + # if we have Django 1.8 available, use last version of django-boostrap3 try: - pkg_resources.require('Django >= 1.7') - django_bootstrap3 = 'django-bootstrap3 >= 5.4,<7.0.0' - django = 'Django >= 1.7,<1.8' - except (pkg_resources.VersionConflict, pkg_resources.DistributionNotFound): - # Else we need to install Django, assume version will be >= 1.8 + pkg_resources.require('Django >= 1.8') + django_bootstrap3 = 'django-bootstrap3 >= 5.4' + django = 'Django >= 1.8,<1.10' + except pkg_resources.VersionConflict: + # Else if we have django 1.7, we need django-boostrap3 < 7.0.0 + try: + pkg_resources.require('Django >= 1.7') + django_bootstrap3 = 'django-bootstrap3 >= 5.4,<7.0.0' + django = 'Django >= 1.7,<1.8' + except (pkg_resources.VersionConflict, pkg_resources.DistributionNotFound): + # Else we need to install Django, assume version will be >= 1.8 + django_bootstrap3 = 'django-bootstrap3 >= 5.4' + django = 'Django >= 1.8,<1.10' + # No version of django installed, assume version will be >= 1.8 + except pkg_resources.DistributionNotFound: django_bootstrap3 = 'django-bootstrap3 >= 5.4' django = 'Django >= 1.8,<1.10' -# No version of django installed, assume version will be >= 1.8 -except pkg_resources.DistributionNotFound: - django_bootstrap3 = 'django-bootstrap3 >= 5.4' - django = 'Django >= 1.8,<1.10' -setup( - name='django-cas-server', - version='0.6.0', - packages=[ - 'cas_server', 'cas_server.migrations', - 'cas_server.management', 'cas_server.management.commands', - 'cas_server.tests' - ], - include_package_data=True, - license='GPLv3', - description=( - 'A Django Central Authentication Service server ' - 'implementing the CAS Protocol 3.0 Specification' - ), - long_description=README, - author='Valentin Samir', - author_email='valentin.samir@crans.org', - classifiers=[ - 'Environment :: Web Environment', - 'Framework :: Django', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 3', - 'Topic :: Internet :: WWW/HTTP', - 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', - ], - package_data={ - 'cas_server': [ - 'templates/cas_server/*', - 'static/cas_server/*', - 'locale/*/LC_MESSAGES/*', - ] - }, - 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, - setup_requires=['pytest-runner'], - tests_require=['pytest', 'pytest-django', 'pytest-pythonpath'], -) + setup( + name='django-cas-server', + version=VERSION, + packages=[ + 'cas_server', 'cas_server.migrations', + 'cas_server.management', 'cas_server.management.commands', + 'cas_server.tests' + ], + include_package_data=True, + license='GPLv3', + description=( + 'A Django Central Authentication Service server ' + 'implementing the CAS Protocol 3.0 Specification' + ), + long_description=README, + author='Valentin Samir', + author_email='valentin.samir@crans.org', + classifiers=[ + 'Environment :: Web Environment', + 'evelopment Status :: 5 - Production/Stable', + 'Framework :: Django', + 'Framework :: Django :: 1.7', + 'Framework :: Django :: 1.8', + 'Framework :: Django :: 1.9', + 'Intended Audience :: Developers', + 'Intended Audience :: System Administrators', + 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', + 'Topic :: System :: Systems Administration :: Authentication/Directory' + ], + package_data={ + 'cas_server': [ + 'templates/cas_server/*', + 'static/cas_server/*', + 'locale/*/LC_MESSAGES/*', + ] + }, + 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, + setup_requires=['pytest-runner'], + tests_require=['pytest', 'pytest-django', 'pytest-pythonpath'], + ) From 232aafcace60e82509501d70d611233a97edc2cf Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sat, 23 Jul 2016 18:43:27 +0200 Subject: [PATCH 16/26] Factorize some code --- cas_server/models.py | 98 ++++++++++++++++++++++++++++++++++++-- cas_server/views.py | 111 +++++++++---------------------------------- 2 files changed, 118 insertions(+), 91 deletions(-) diff --git a/cas_server/models.py b/cas_server/models.py index 23b9587..5ca1296 100644 --- a/cas_server/models.py +++ b/cas_server/models.py @@ -736,6 +736,9 @@ class Ticket(models.Model): #: requests. TIMEOUT = settings.CAS_TICKET_TIMEOUT + class DoesNotExist(Exception): + pass + def __str__(self): return u"Ticket-%s" % self.pk @@ -806,19 +809,108 @@ class Ticket(models.Model): ) @staticmethod - def get_class(ticket): + def get_class(ticket, classes=None): """ Return the ticket class of ``ticket`` :param unicode ticket: A ticket + :param list classes: Optinal arguement. A list of possible :class:`Ticket` subclasses :return: The class corresponding to ``ticket`` (:class:`ServiceTicket` or - :class:`ProxyTicket` or :class:`ProxyGrantingTicket`) if found, ``None`` otherwise. + :class:`ProxyTicket` or :class:`ProxyGrantingTicket`) if found among ``classes, + ``None`` otherwise. :rtype: :obj:`type` or :obj:`NoneType` """ - for ticket_class in [ServiceTicket, ProxyTicket, ProxyGrantingTicket]: + if classes is None: # pragma: no cover (not used) + classes = [ServiceTicket, ProxyTicket, ProxyGrantingTicket] + for ticket_class in classes: if ticket.startswith(ticket_class.PREFIX): return ticket_class + def username(self): + """ + The username to send on ticket validation + + :return: The value of the corresponding user attribute if + :attr:`service_pattern`.user_field is set, the user username otherwise. + """ + if self.service_pattern.user_field and self.user.attributs.get( + self.service_pattern.user_field + ): + username = self.user.attributs[self.service_pattern.user_field] + if isinstance(username, list): + # the list is not empty because we wont generate a ticket with a user_field + # that evaluate to False + username = username[0] + else: + username = self.user.username + return username + + def attributs_flat(self): + """ + generate attributes list for template rendering + + :return: An list of (attribute name, attribute value) of all user attributes flatened + (no nested list) + :rtype: :obj:`list` of :obj:`tuple` of :obj:`unicode` + """ + attributes = [] + for key, value in self.attributs.items(): + if isinstance(value, list): + for elt in value: + attributes.append((key, elt)) + else: + attributes.append((key, value)) + return attributes + + @classmethod + def get(cls, ticket, renew=False, service=None): + """ + Search the database for a valid ticket with provided arguments + + :param unicode ticket: A ticket value + :param bool renew: Is authentication renewal needed + :param unicode service: Optional argument. The ticket service + :raises Ticket.DoesNotExist: if no class is found for the ticket prefix + :raises cls.DoesNotExist: if ``ticket`` value is not found in th database + :return: a :class:`Ticket` instance + :rtype: Ticket + """ + # If the method class is the ticket abstract class, search for the submited ticket + # class using its prefix. Assuming ticket is a ProxyTicket or a ServiceTicket + if cls == Ticket: + ticket_class = cls.get_class(ticket, classes=[ServiceTicket, ProxyTicket]) + # else use the method class + else: + ticket_class = cls + # If ticket prefix is wrong, raise DoesNotExist + if cls != Ticket and not ticket.startswith(cls.PREFIX): + raise Ticket.DoesNotExist() + if ticket_class: + # search for the ticket that is not yet validated and is still valid + ticket_queryset = ticket_class.objects.filter( + value=ticket, + validate=False, + creation__gt=(timezone.now() - timedelta(seconds=ticket_class.VALIDITY)) + ) + # if service is specified, add it the the queryset + if service is not None: + ticket_queryset = ticket_queryset.filter(service=service) + # only require renew if renew is True, otherwise it do not matter if renew is True + # or False. + if renew: + ticket_queryset = ticket_queryset.filter(renew=True) + # fetch the ticket ``MultipleObjectsReturned`` is never raised as the ticket value + # is unique across the database + ticket = ticket_queryset.get() + # For ServiceTicket and Proxyticket, mark it as validated before returning + if ticket_class != ProxyGrantingTicket: + ticket.validate = True + ticket.save() + return ticket + # If no class found for the ticket, raise DoesNotExist + else: + raise Ticket.DoesNotExist() + @python_2_unicode_compatible class ServiceTicket(Ticket): diff --git a/cas_server/views.py b/cas_server/views.py index 2bc3b51..ea2bbfd 100644 --- a/cas_server/views.py +++ b/cas_server/views.py @@ -36,30 +36,13 @@ import cas_server.forms as forms import cas_server.models as models from .utils import json_response -from .models import ServiceTicket, ProxyTicket, ProxyGrantingTicket +from .models import Ticket, ServiceTicket, ProxyTicket, ProxyGrantingTicket from .models import ServicePattern, FederatedIendityProvider, FederatedUser from .federate import CASFederateValidateUser logger = logging.getLogger(__name__) -class AttributesMixin(object): - """mixin for the attributs methode""" - - # pylint: disable=too-few-public-methods - - def attributes(self): - """regerate attributes list for template rendering""" - attributes = [] - for key, value in self.ticket.attributs.items(): - if isinstance(value, list): - for elt in value: - attributes.append((key, elt)) - else: - attributes.append((key, value)) - return attributes - - class LogoutMixin(object): """destroy CAS session utils""" def logout(self, all_session=False): @@ -243,7 +226,8 @@ class FederateAuth(View): :param django.http.HttpRequest request: The current request object :param cas_server.models.FederatedIendityProvider provider: the user identity provider :return: The user CAS client object - :rtype: :class:`federate.CASFederateValidateUser` + :rtype: :class:`federate.CASFederateValidateUser + ` """ # compute the current url, ignoring ticket dans provider GET parameters service_url = utils.get_current_url(request, {"ticket", "provider"}) @@ -961,18 +945,9 @@ class Validate(View): # service and ticket parameters are mandatory if service and ticket: try: - ticket_queryset = ServiceTicket.objects.filter( - value=ticket, - service=service, - validate=False, - creation__gt=(timezone.now() - timedelta(seconds=ServiceTicket.VALIDITY)) - ) - if renew: - ticket = ticket_queryset.get(renew=True) - else: - ticket = ticket_queryset.get() - ticket.validate = True - ticket.save() + # search for the ticket, associated at service that is not yet validated but is + # still valid + ticket = ServiceTicket.get(ticket, renew, service) logger.info( "Validate: Service ticket %s validated, user %s authenticated on service %s" % ( ticket.value, @@ -980,19 +955,8 @@ class Validate(View): ticket.service ) ) - if (ticket.service_pattern.user_field and - ticket.user.attributs.get(ticket.service_pattern.user_field)): - username = ticket.user.attributs.get( - ticket.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 = ticket.user.username return HttpResponse( - u"yes\n%s\n" % username, + u"yes\n%s\n" % ticket.username(), content_type="text/plain; charset=utf-8" ) except ServiceTicket.DoesNotExist: @@ -1018,6 +982,7 @@ class ValidateError(Exception): code = None #: The error message msg = None + def __init__(self, code, msg=""): self.code = code self.msg = msg @@ -1087,19 +1052,11 @@ class ValidateService(View): self.ticket, proxies = self.process_ticket() # prepare template rendering context params = { - 'username': self.ticket.user.username, - 'attributes': self.attributes(), + 'username': self.ticket.username(), + 'attributes': self.ticket.attributs_flat(), 'proxies': proxies } - if (self.ticket.service_pattern.user_field and - self.ticket.user.attributs.get(self.ticket.service_pattern.user_field)): - params['username'] = self.ticket.user.attributs.get( - self.ticket.service_pattern.user_field - ) - if isinstance(params['username'], list): - # the list is not empty because we wont generate a ticket with a user_field - # that evaluate to False - params['username'] = params['username'][0] + # if pgtUrl is set, require https or localhost if self.pgt_url and ( self.pgt_url.startswith("https://") or re.match("^http://(127\.0\.0\.1|localhost)(:[0-9]+)?(/.*)?$", self.pgt_url) @@ -1278,11 +1235,7 @@ class Proxy(View): u'the service %s do not allow proxy ticket' % self.target_service ) # is the proxy granting ticket valid - ticket = ProxyGrantingTicket.objects.get( - value=self.pgt, - creation__gt=(timezone.now() - timedelta(seconds=ProxyGrantingTicket.VALIDITY)), - validate=False - ) + ticket = ProxyGrantingTicket.get(self.pgt) # is the pgt user allowed on the target service pattern.check_user(ticket.user) pticket = ticket.user.get_ticket( @@ -1304,7 +1257,7 @@ class Proxy(View): {'ticket': pticket.value}, content_type="text/xml; charset=utf-8" ) - except ProxyGrantingTicket.DoesNotExist: + except (Ticket.DoesNotExist, ProxyGrantingTicket.DoesNotExist): raise ValidateError(u'INVALID_TICKET', u'PGT %s not found' % self.pgt) except ServicePattern.DoesNotExist: raise ValidateError(u'UNAUTHORIZED_SERVICE', self.target_service) @@ -1322,6 +1275,7 @@ class SamlValidateError(Exception): code = None #: The error message msg = None + def __init__(self, code, msg=""): self.code = code self.msg = msg @@ -1351,7 +1305,7 @@ class SamlValidateError(Exception): ) -class SamlValidate(View, AttributesMixin): +class SamlValidate(View): """SAML ticket validation""" request = None target = None @@ -1375,7 +1329,6 @@ class SamlValidate(View, AttributesMixin): :return: the rendering of ``cas_server/samlValidate.xml`` if no error is raised, else the rendering of ``cas_server/samlValidateError.xml``. :rtype: django.http.HttpResponse - """ self.request = request self.target = request.GET.get('TARGET') @@ -1384,24 +1337,14 @@ class SamlValidate(View, AttributesMixin): self.ticket = self.process_ticket() expire_instant = (self.ticket.creation + timedelta(seconds=self.ticket.VALIDITY)).isoformat() - attributes = self.attributes() params = { 'IssueInstant': timezone.now().isoformat(), 'expireInstant': expire_instant, 'Recipient': self.target, 'ResponseID': utils.gen_saml_id(), - 'username': self.ticket.user.username, - 'attributes': attributes + 'username': self.ticket.username(), + 'attributes': self.ticket.attributs_flat() } - if (self.ticket.service_pattern.user_field and - self.ticket.user.attributs.get(self.ticket.service_pattern.user_field)): - params['username'] = self.ticket.user.attributs.get( - self.ticket.service_pattern.user_field - ) - if isinstance(params['username'], list): - # the list is not empty because we wont generate a ticket with a user_field - # that evaluate to False - params['username'] = params['username'][0] logger.info( "SamlValidate: ticket %s validated for user %s on service %s." % ( self.ticket.value, @@ -1435,20 +1378,7 @@ class SamlValidate(View, AttributesMixin): try: auth_req = self.root.getchildren()[1].getchildren()[0] ticket = auth_req.getchildren()[0].text - ticket_class = models.Ticket.get_class(ticket) - if ticket_class: - ticket = ticket_class.objects.get( - value=ticket, - validate=False, - creation__gt=(timezone.now() - timedelta(seconds=ServiceTicket.VALIDITY)) - ) - else: - raise SamlValidateError( - u'AuthnFailed', - u'ticket %s should begin with PT- or ST-' % ticket - ) - ticket.validate = True - ticket.save() + ticket = models.Ticket.get(ticket) if ticket.service != self.target: raise SamlValidateError( u'AuthnFailed', @@ -1457,5 +1387,10 @@ class SamlValidate(View, AttributesMixin): return ticket except (IndexError, KeyError): raise SamlValidateError(u'VersionMismatch') + except Ticket.DoesNotExist: + raise SamlValidateError( + u'AuthnFailed', + u'ticket %s should begin with PT- or ST-' % ticket + ) except (ServiceTicket.DoesNotExist, ProxyTicket.DoesNotExist): raise SamlValidateError(u'AuthnFailed', u'ticket %s not found' % ticket) From 2ce03972e52bd36dd114e8c1ac25ac6e57d48ae0 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sat, 23 Jul 2016 18:45:55 +0200 Subject: [PATCH 17/26] Include docs, Makefile, coverage config and tests config to source package --- MANIFEST.in | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/MANIFEST.in b/MANIFEST.in index bc6a3b2..3f968f7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,7 +1,21 @@ include tox.ini include LICENSE include README.rst +include .coveragerc +include Makefile +include pytest.ini +include requirements-dev.txt +include requirements.txt prune .tox recursive-include cas_server/static * recursive-include cas_server/templates * recursive-include cas_server/locale * + +include docs/conf.py +include docs/index.rst +include docs/Makefile +include docs/README.rst +recursive-include docs/_ext * +recursive-include docs/package * +recursive-include docs/_static * +recursive-include docs/_templates * From 1636860e480195f96b5b9a1539cb2be84ac15596 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sat, 23 Jul 2016 18:46:36 +0200 Subject: [PATCH 18/26] Rules to publish wheel release --- Makefile | 3 +++ setup.cfg | 2 ++ 2 files changed, 5 insertions(+) diff --git a/Makefile b/Makefile index a186ddc..68fd801 100644 --- a/Makefile +++ b/Makefile @@ -70,3 +70,6 @@ test_venv/bin/sphinx-build: test_venv docs: test_venv/bin/sphinx-build bash -c "source test_venv/bin/activate; cd docs; make html" + +publish_pypi_release: + python setup.py sdist bdist_wheel upload --sign diff --git a/setup.cfg b/setup.cfg index f1602a2..dcfa190 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,3 +9,5 @@ tag_svn_revision = 0 [aliases] test=pytest +[bdist_wheel] +universal = 1 From 5f5f3595c8137c1c8b6c051b957f8a6426feb118 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sat, 23 Jul 2016 18:47:03 +0200 Subject: [PATCH 19/26] Update README.rst put image link at the bottom of the document --- README.rst | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/README.rst b/README.rst index 151c32f..90001c8 100644 --- a/README.rst +++ b/README.rst @@ -1,20 +1,7 @@ CAS Server ########## -.. image:: https://badges.genua.fr/travis/nitmir/django-cas-server/master.svg - :target: https://travis-ci.org/nitmir/django-cas-server - -.. image:: https://badges.genua.fr/pypi/v/django-cas-server.svg - :target: https://pypi.python.org/pypi/django-cas-server - -.. image:: https://badges.genua.fr/pypi/l/django-cas-server.svg - :target: https://www.gnu.org/licenses/gpl-3.0.html - -.. image:: https://badges.genua.fr/codacy/grade/255c21623d6946ef8802fa7995b61366/master.svg - :target: https://www.codacy.com/app/valentin-samir/django-cas-server - -.. image:: https://badges.genua.fr/codacy/coverage/255c21623d6946ef8802fa7995b61366/master.svg - :target: https://www.codacy.com/app/valentin-samir/django-cas-server +|travis| |version| |lisence| |codacy| |coverage| CAS Server is a Django application implementing the `CAS Protocol 3.0 Specification `_. @@ -489,3 +476,20 @@ You could for example do as bellow : .. code-block:: 10 0 * * * cas-user /path/to/project/manage.py cas_clean_federate + + + +.. |travis| image:: https://badges.genua.fr/travis/nitmir/django-cas-server/master.svg + :target: https://travis-ci.org/nitmir/django-cas-server + +.. |version| image:: https://badges.genua.fr/pypi/v/django-cas-server.svg + :target: https://pypi.python.org/pypi/django-cas-server + +.. |lisence| image:: https://badges.genua.fr/pypi/l/django-cas-server.svg + :target: https://www.gnu.org/licenses/gpl-3.0.html + +.. |codacy| image:: https://badges.genua.fr/codacy/grade/255c21623d6946ef8802fa7995b61366/master.svg + :target: https://www.codacy.com/app/valentin-samir/django-cas-server + +.. |coverage| image:: https://badges.genua.fr/codacy/coverage/255c21623d6946ef8802fa7995b61366/master.svg + :target: https://www.codacy.com/app/valentin-samir/django-cas-server From 9bae3711998ef19bb0bb8043bb37786487962a54 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sat, 23 Jul 2016 18:47:32 +0200 Subject: [PATCH 20/26] Add serviceValidate ProxyTicket tests --- cas_server/tests/test_view.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/cas_server/tests/test_view.py b/cas_server/tests/test_view.py index fef7ec3..79c103e 100644 --- a/cas_server/tests/test_view.py +++ b/cas_server/tests/test_view.py @@ -1112,7 +1112,9 @@ class ValidateServiceTestCase(TestCase, XmlContent): name="localhost", pattern="^https?://127\.0\.0\.1(:[0-9]+)?(/.*)?$", # 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) models.ReplaceAttributName.objects.create(name="*", service_pattern=self.service_pattern) @@ -1190,6 +1192,25 @@ class ValidateServiceTestCase(TestCase, XmlContent): # the attributes settings.CAS_TEST_ATTRIBUTES self.assert_success(response, settings.CAS_TEST_USER, settings.CAS_TEST_ATTRIBUTES) + def test_validate_proxy(self): + 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): """test with a valid (ticket, service) asking for auth renewal""" # case 1 client is renewing and service ask for renew From 018a5951a2682de3bd82f69597006e660a0c709f Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sat, 23 Jul 2016 18:47:52 +0200 Subject: [PATCH 21/26] Update urlpattern for django 1.10 --- cas_server/urls.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cas_server/urls.py b/cas_server/urls.py index 5881557..aa014f2 100644 --- a/cas_server/urls.py +++ b/cas_server/urls.py @@ -10,14 +10,13 @@ # # (c) 2015-2016 Valentin Samir """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.decorators.debug import sensitive_post_parameters, sensitive_variables from cas_server import views -urlpatterns = patterns( - '', +urlpatterns = [ url(r'^$', RedirectView.as_view(pattern_name="cas_server:login")), url( '^login$', @@ -60,4 +59,4 @@ urlpatterns = patterns( name='auth' ), url("^federate(?:/(?P([^/]+)))?$", views.FederateAuth.as_view(), name='federateAuth'), -) +] From df1d57bca1f4420ddbf36f5b4159832da1c9f8c6 Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sat, 23 Jul 2016 18:50:47 +0200 Subject: [PATCH 22/26] Add python 3.5 tox/travis tests --- .travis.yml | 4 ++++ tox.ini | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/.travis.yml b/.travis.yml index d4b800e..2d832eb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,8 @@ language: python python: - "2.7" + - "3.4" + - "3.5" env: matrix: - TOX_ENV=coverage @@ -12,6 +14,8 @@ env: - TOX_ENV=py34-django17 - TOX_ENV=py34-django18 - TOX_ENV=py34-django19 + - TOX_ENV=py35-django18 + - TOX_ENV=py35-django19 cache: directories: - $HOME/.cache/pip/http/ diff --git a/tox.ini b/tox.ini index 17a7bfe..bdf50f0 100644 --- a/tox.ini +++ b/tox.ini @@ -8,6 +8,8 @@ envlist= py34-django17, py34-django18, py34-django19, + py35-django18, + py35-django19, [flake8] max-line-length=100 @@ -69,6 +71,19 @@ deps = Django>=1.9,<1.10 {[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] basepython=python deps=flake8 From d6b91033d44ce6ae4bb52224d9710a215b417f9d Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sat, 23 Jul 2016 19:02:50 +0200 Subject: [PATCH 23/26] Correct travis to stick python version to tox envs --- .travis.yml | 41 ++++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/.travis.yml b/.travis.yml index 2d832eb..e7583b2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,21 +1,28 @@ language: python -python: - - "2.7" - - "3.4" - - "3.5" -env: - matrix: - - TOX_ENV=coverage - - TOX_ENV=flake8 - - TOX_ENV=check_rst - - TOX_ENV=py27-django17 - - TOX_ENV=py27-django18 - - TOX_ENV=py27-django19 - - TOX_ENV=py34-django17 - - TOX_ENV=py34-django18 - - TOX_ENV=py34-django19 - - TOX_ENV=py35-django18 - - TOX_ENV=py35-django19 +matrix: + include: + - python: "2.7" + env: TOX_ENV=coverage + - python: "2.7" + env: TOX_ENV=flake8 + - python: "2.7" + env: TOX_ENV=check_rst + - python: "2.7" + env: TOX_ENV=py27-django17 + - python: "2.7" + env: TOX_ENV=py27-django18 + - python: "2.7" + env: TOX_ENV=py27-django19 + - python: "3.4" + env: TOX_ENV=py34-django17 + - python: "3.4" + env: TOX_ENV=py34-django18 + - python: "3.4" + env: TOX_ENV=py34-django19 + - python: "3.5" + env: TOX_ENV=py35-django18 + - python: "3.5" + env: TOX_ENV=py35-django19 cache: directories: - $HOME/.cache/pip/http/ From ff9566289da1f5d73479745e82c3b7bde3fc6bbc Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sat, 23 Jul 2016 19:12:44 +0200 Subject: [PATCH 24/26] Add missing docstrings --- cas_server/models.py | 1 + cas_server/tests/test_view.py | 1 + 2 files changed, 2 insertions(+) diff --git a/cas_server/models.py b/cas_server/models.py index 5ca1296..fbfd1e4 100644 --- a/cas_server/models.py +++ b/cas_server/models.py @@ -737,6 +737,7 @@ class Ticket(models.Model): TIMEOUT = settings.CAS_TICKET_TIMEOUT class DoesNotExist(Exception): + """raised in :meth:`Ticket.get` then ticket prefix and ticket classes mismatch""" pass def __str__(self): diff --git a/cas_server/tests/test_view.py b/cas_server/tests/test_view.py index 79c103e..7ae0888 100644 --- a/cas_server/tests/test_view.py +++ b/cas_server/tests/test_view.py @@ -1193,6 +1193,7 @@ class ValidateServiceTestCase(TestCase, XmlContent): 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) From 3ff4bb16a94d45acf07427ee4eee61ebe22adf6e Mon Sep 17 00:00:00 2001 From: Valentin Samir Date: Sun, 24 Jul 2016 01:49:03 +0200 Subject: [PATCH 25/26] Drop dependancies django-picklefield and django-bootstrap3 --- .gitignore | 1 - Makefile | 2 +- README.rst | 23 ++--- cas_server/default_settings.py | 8 ++ cas_server/federate.py | 2 +- cas_server/forms.py | 27 +++++- .../0001_squashed_0021_auto_20150611_2102.py | 7 +- .../migrations/0005_auto_20160616_1018.py | 3 +- .../migrations/0007_auto_20160723_2252.py | 56 +++++++++++ cas_server/models.py | 31 +++++- cas_server/templates/cas_server/base.html | 95 ++++++++++++------- cas_server/templates/cas_server/form.html | 25 +++++ cas_server/templates/cas_server/logged.html | 4 +- cas_server/templates/cas_server/login.html | 25 ++--- cas_server/templates/cas_server/logout.html | 1 - cas_server/templates/cas_server/warn.html | 5 +- cas_server/tests/settings.py | 1 - cas_server/utils.py | 12 +++ requirements-dev.txt | 2 - requirements.txt | 2 - setup.py | 26 +---- 21 files changed, 248 insertions(+), 110 deletions(-) create mode 100644 cas_server/migrations/0007_auto_20160723_2252.py create mode 100644 cas_server/templates/cas_server/form.html diff --git a/.gitignore b/.gitignore index f2b02b7..c05c31f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ *.swp build/ -bootstrap3 cas/ dist/ db.sqlite3 diff --git a/Makefile b/Makefile index 68fd801..c719834 100644 --- a/Makefile +++ b/Makefile @@ -44,7 +44,7 @@ test_venv/cas/manage.py: test_venv mkdir -p test_venv/cas test_venv/bin/django-admin startproject cas test_venv/cas ln -s ../../cas_server test_venv/cas/cas_server - sed -i "s/'django.contrib.staticfiles',/'django.contrib.staticfiles',\n 'bootstrap3',\n 'cas_server',/" test_venv/cas/cas/settings.py + sed -i "s/'django.contrib.staticfiles',/'django.contrib.staticfiles',\n 'cas_server',/" test_venv/cas/cas/settings.py sed -i "s/'django.middleware.clickjacking.XFrameOptionsMiddleware',/'django.middleware.clickjacking.XFrameOptionsMiddleware',\n 'django.middleware.locale.LocaleMiddleware',/" test_venv/cas/cas/settings.py sed -i 's/from django.conf.urls import url/from django.conf.urls import url, include/' test_venv/cas/cas/urls.py sed -i "s@url(r'^admin/', admin.site.urls),@url(r'^admin/', admin.site.urls),\n url(r'^', include('cas_server.urls', namespace='cas_server')),@" test_venv/cas/cas/urls.py diff --git a/README.rst b/README.rst index 90001c8..8675112 100644 --- a/README.rst +++ b/README.rst @@ -9,13 +9,6 @@ CAS Server is a Django application implementing the `CAS Protocol 3.0 Specificat By default, the authentication process use django internal users but you can easily use any sources (see auth classes in the auth.py file) -The default login/logout template use `django-bootstrap3 `__ -but you can use your own templates using settings variables. - -Note that for Django 1.7 compatibility, you need a version of -`django-bootstrap3 `__ < 7.0.0 -like the 6.2.2 version. - .. contents:: Table of Contents Features @@ -39,8 +32,6 @@ Dependencies * Django >= 1.7 < 1.10 * requests >= 2.4 * requests_futures >= 0.9.5 -* django-picklefield >= 0.3.1 -* django-bootstrap3 >= 5.4 (< 7.0.0 if using django 1.7) * lxml >= 3.4 * six >= 1 @@ -55,7 +46,7 @@ The recommended installation mode is to use a virtualenv with ``--system-site-pa On debian like systems:: - $ sudo apt-get install python-django python-requests python-django-picklefield python-six python-lxml + $ sudo apt-get install python-django python-requests python-six python-lxml python-requests-futures On debian jessie, you can use the version of python-django available in the `backports `_. @@ -105,7 +96,6 @@ Quick start INSTALLED_APPS = ( 'django.contrib.admin', ... - 'bootstrap3', 'cas_server', ) @@ -173,6 +163,17 @@ Template settings * ``CAS_LOGO_URL``: URL to the logo showed in the up left corner on the default templates. Set it to ``False`` to disable it. +* ``CAS_COMPONENT_URLS``: URLs to css and javascript external components. It is a dictionnary + and it must have the five following keys: ``"bootstrap3_css"``, ``"bootstrap3_js"``, + ``"html5shiv"``, ``"respond"``, ``"jquery"``. The default is:: + + { + "bootstrap3_css": "//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css", + "bootstrap3_js": "//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/js/bootstrap.min.js", + "html5shiv": "//oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js", + "respond": "//oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js", + "jquery": "//code.jquery.com/jquery.min.js", + } * ``CAS_LOGIN_TEMPLATE``: Path to the template showed on ``/login`` then the user is not autenticated. The default is ``"cas_server/login.html"``. diff --git a/cas_server/default_settings.py b/cas_server/default_settings.py index 3c08af3..c7b2b12 100644 --- a/cas_server/default_settings.py +++ b/cas_server/default_settings.py @@ -18,6 +18,14 @@ from importlib import import_module #: URL to the logo showed in the up left corner on the default templates. CAS_LOGO_URL = static("cas_server/logo.png") +#: 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 diff --git a/cas_server/federate.py b/cas_server/federate.py index 156d3ea..2cfd90e 100644 --- a/cas_server/federate.py +++ b/cas_server/federate.py @@ -84,7 +84,7 @@ class CASFederateValidateUser(object): if username is not None: if attributs is None: attributs = {} - attributs["provider"] = self.provider + attributs["provider"] = self.provider.suffix self.username = username self.attributs = attributs user = FederatedUser.objects.update_or_create( diff --git a/cas_server/forms.py b/cas_server/forms.py index 4b35008..03c7515 100644 --- a/cas_server/forms.py +++ b/cas_server/forms.py @@ -18,7 +18,28 @@ import cas_server.utils as utils import cas_server.models as models -class WarnForm(forms.Form): +class BootsrapForm(forms.Form): + """Form base class to use boostrap then rendering the form fields""" + def __init__(self, *args, **kwargs): + super(BootsrapForm, self).__init__(*args, **kwargs) + for (name, field) in self.fields.items(): + # Only tweak the fiel if it will be displayed + if not isinstance(field.widget, forms.HiddenInput): + # tell to display the field (used in form.html) + self[name].display = True + attrs = {} + if isinstance(field.widget, forms.CheckboxInput): + self[name].checkbox = True + else: + attrs['class'] = "form-control" + if field.label: + attrs["placeholder"] = field.label + if field.required: + attrs["required"] = "required" + field.widget.attrs.update(attrs) + + +class WarnForm(BootsrapForm): """ Bases: :class:`django.forms.Form` @@ -38,7 +59,7 @@ class WarnForm(forms.Form): lt = forms.CharField(widget=forms.HiddenInput(), required=False) -class FederateSelect(forms.Form): +class FederateSelect(BootsrapForm): """ Bases: :class:`django.forms.Form` @@ -66,7 +87,7 @@ class FederateSelect(forms.Form): renew = forms.BooleanField(widget=forms.HiddenInput(), required=False) -class UserCredential(forms.Form): +class UserCredential(BootsrapForm): """ Bases: :class:`django.forms.Form` diff --git a/cas_server/migrations/0001_squashed_0021_auto_20150611_2102.py b/cas_server/migrations/0001_squashed_0021_auto_20150611_2102.py index a2000bc..c3d3785 100644 --- a/cas_server/migrations/0001_squashed_0021_auto_20150611_2102.py +++ b/cas_server/migrations/0001_squashed_0021_auto_20150611_2102.py @@ -4,7 +4,6 @@ from __future__ import unicode_literals from django.db import models, migrations import django.db.models.deletion import cas_server.utils -import picklefield.fields class Migration(migrations.Migration): @@ -31,7 +30,7 @@ class Migration(migrations.Migration): name='ProxyGrantingTicket', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('attributs', picklefield.fields.PickledObjectField(editable=False)), + ('attributs', models.TextField(blank=True, default=None, null=True)), ('validate', models.BooleanField(default=False)), ('service', models.TextField()), ('creation', models.DateTimeField(auto_now_add=True)), @@ -47,7 +46,7 @@ class Migration(migrations.Migration): name='ProxyTicket', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('attributs', picklefield.fields.PickledObjectField(editable=False)), + ('attributs', models.TextField(blank=True, default=None, null=True)), ('validate', models.BooleanField(default=False)), ('service', models.TextField()), ('creation', models.DateTimeField(auto_now_add=True)), @@ -80,7 +79,7 @@ class Migration(migrations.Migration): name='ServiceTicket', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('attributs', picklefield.fields.PickledObjectField(editable=False)), + ('attributs', models.TextField(blank=True, default=None, null=True)), ('validate', models.BooleanField(default=False)), ('service', models.TextField()), ('creation', models.DateTimeField(auto_now_add=True)), diff --git a/cas_server/migrations/0005_auto_20160616_1018.py b/cas_server/migrations/0005_auto_20160616_1018.py index fea9167..8d361b9 100644 --- a/cas_server/migrations/0005_auto_20160616_1018.py +++ b/cas_server/migrations/0005_auto_20160616_1018.py @@ -3,7 +3,6 @@ from __future__ import unicode_literals from django.db import migrations, models -import picklefield.fields import django.db.models.deletion @@ -41,7 +40,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('username', models.CharField(max_length=124)), ('provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cas_server.FederatedIendityProvider')), - ('attributs', picklefield.fields.PickledObjectField(editable=False)), + ('attributs', models.TextField(blank=True, default=None, null=True)), ('ticket', models.CharField(max_length=255)), ('last_update', models.DateTimeField(auto_now=True)), ], diff --git a/cas_server/migrations/0007_auto_20160723_2252.py b/cas_server/migrations/0007_auto_20160723_2252.py new file mode 100644 index 0000000..fd0c8a1 --- /dev/null +++ b/cas_server/migrations/0007_auto_20160723_2252.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.8 on 2016-07-23 22:52 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cas_server', '0006_auto_20160706_1727'), + ] + + operations = [ + migrations.RemoveField( + model_name='federateduser', + name='attributs', + ), + migrations.RemoveField( + model_name='proxygrantingticket', + name='attributs', + ), + migrations.RemoveField( + model_name='proxyticket', + name='attributs', + ), + migrations.RemoveField( + model_name='serviceticket', + name='attributs', + ), + migrations.AddField( + model_name='federateduser', + name='_attributs', + field=models.TextField(blank=True, default=None, null=True), + ), + migrations.AddField( + model_name='proxygrantingticket', + name='_attributs', + field=models.TextField(blank=True, default=None, null=True), + ), + migrations.AddField( + model_name='proxyticket', + name='_attributs', + field=models.TextField(blank=True, default=None, null=True), + ), + migrations.AddField( + model_name='serviceticket', + name='_attributs', + field=models.TextField(blank=True, default=None, null=True), + ), + migrations.AlterField( + model_name='federatediendityprovider', + name='suffix', + field=models.CharField(help_text='Suffix append to backend CAS returned username: ``returned_username`` @ ``suffix``.', max_length=30, unique=True, verbose_name='suffix'), + ), + ] diff --git a/cas_server/models.py b/cas_server/models.py index fbfd1e4..6e87d40 100644 --- a/cas_server/models.py +++ b/cas_server/models.py @@ -18,7 +18,6 @@ from django.contrib import messages from django.utils.translation import ugettext_lazy as _ from django.utils import timezone from django.utils.encoding import python_2_unicode_compatible -from picklefield.fields import PickledObjectField import re import sys @@ -140,8 +139,8 @@ class FederatedUser(models.Model): username = models.CharField(max_length=124) #: A foreign key to :class:`FederatedIendityProvider` provider = models.ForeignKey(FederatedIendityProvider, on_delete=models.CASCADE) - #: The user attributes returned by the CAS backend on successful ticket validation - attributs = PickledObjectField() + #: The user attributes json encoded + _attributs = models.TextField(default=None, null=True, blank=True) #: The last ticket used to authenticate :attr:`username` against :attr:`provider` ticket = models.CharField(max_length=255) #: Last update timespampt. Usually, the last time :attr:`ticket` has been set. @@ -150,6 +149,17 @@ class FederatedUser(models.Model): def __str__(self): return self.federated_username + @property + def attributs(self): + """The user attributes returned by the CAS backend on successful ticket validation""" + if self._attributs is not None: + return utils.json.loads(self._attributs) + + @attributs.setter + def attributs(self, value): + """attributs property setter""" + self._attributs = utils.json_encode(value) + @property def federated_username(self): """The federated username with a suffix for the current :class:`FederatedUser`.""" @@ -712,8 +722,8 @@ class Ticket(models.Model): abstract = True #: ForeignKey to a :class:`User`. user = models.ForeignKey(User, related_name="%(class)s") - #: The user attributes to be transmited to the service on successful validation - attributs = PickledObjectField() + #: The user attributes to transmit to the service json encoded + _attributs = models.TextField(default=None, null=True, blank=True) #: A boolean. ``True`` if the ticket has been validated validate = models.BooleanField(default=False) #: The service url for the ticket @@ -736,6 +746,17 @@ class Ticket(models.Model): #: requests. TIMEOUT = settings.CAS_TICKET_TIMEOUT + @property + def attributs(self): + """The user attributes to be transmited to the service on successful validation""" + if self._attributs is not None: + return utils.json.loads(self._attributs) + + @attributs.setter + def attributs(self, value): + """attributs property setter""" + self._attributs = utils.json_encode(value) + class DoesNotExist(Exception): """raised in :meth:`Ticket.get` then ticket prefix and ticket classes mismatch""" pass diff --git a/cas_server/templates/cas_server/base.html b/cas_server/templates/cas_server/base.html index bebf439..db61e1b 100644 --- a/cas_server/templates/cas_server/base.html +++ b/cas_server/templates/cas_server/base.html @@ -1,36 +1,63 @@ -{% extends 'bootstrap3/bootstrap3.html' %} {% load i18n %} -{% block bootstrap3_title %}{% block title %}{% trans "Central Authentication Service" %}{% endblock %}{% endblock %} - {% load staticfiles %} -{% load bootstrap3 %} - -{% block bootstrap3_extra_head %} - - -{% endblock %} - -{% block bootstrap3_content %} -
-{% if auto_submit %}{% endif %} -
-
-
-{% if auto_submit %}{% endif %} -{% block content %} -{% endblock %} -
-
-
-
-{% endblock %} + + + + + + + {% block title %}{% trans "Central Authentication Service" %}{% endblock %} + + + + + + + + +
+ {% if auto_submit %}{% endif %} +
+
+
+ {% block ante_messages %}{% endblock %} + {% if auto_submit %}{% endif %} + {% block content %}{% endblock %} +
+
+
+
+ + + + diff --git a/cas_server/templates/cas_server/form.html b/cas_server/templates/cas_server/form.html new file mode 100644 index 0000000..5ac1463 --- /dev/null +++ b/cas_server/templates/cas_server/form.html @@ -0,0 +1,25 @@ +{% for error in form.non_field_errors %} +
+ + {{error}} +
+{% endfor %} +{% for field in form %}{% if field.display %} +
{% spaceless %} + {% if field.checkbox %} +
+ {% else %} + + {{field}} + {% endif %} + {% for error in field.errors %} + {{error}} + {% endfor %} +{% endspaceless %}
+{% else %}{{field}}{% endif %}{% endfor %} diff --git a/cas_server/templates/cas_server/logged.html b/cas_server/templates/cas_server/logged.html index 9c8bb38..f29445b 100644 --- a/cas_server/templates/cas_server/logged.html +++ b/cas_server/templates/cas_server/logged.html @@ -1,6 +1,4 @@ {% extends "cas_server/base.html" %} -{% load bootstrap3 %} -{% load staticfiles %} {% load i18n %} {% block content %} @@ -10,7 +8,7 @@ {% trans "Log me out from all my sessions" %}
- {% bootstrap_button _('Logout') size='lg' button_type="submit" button_class="btn-danger btn-block"%} + {% endblock %} diff --git a/cas_server/templates/cas_server/login.html b/cas_server/templates/cas_server/login.html index d4559fe..d6adc64 100644 --- a/cas_server/templates/cas_server/login.html +++ b/cas_server/templates/cas_server/login.html @@ -1,18 +1,19 @@ {% extends "cas_server/base.html" %} -{% load bootstrap3 %} -{% load staticfiles %} {% load i18n %} + +{% block ante_messages %} +{% if auto_submit %}{% endif %} +{% endblock %} {% block content %} - + {% if auto_submit %}