diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..de9840f --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,5 @@ +****************** +1.0.0 (unreleased) +****************** + +- First official release. diff --git a/README.rst b/README.rst index e01082e..fab0404 100644 --- a/README.rst +++ b/README.rst @@ -1,22 +1,51 @@ -================== +################## django-allauth-cas -================== +################## .. image:: https://travis-ci.org/aureplop/django-allauth-cas.svg?branch=master - :target: https://travis-ci.org/aureplop/django-allauth-cas + :target: https://travis-ci.org/aureplop/django-allauth-cas .. image:: https://coveralls.io/repos/github/aureplop/django-allauth-cas/badge.svg?branch=master - :target: https://coveralls.io/github/aureplop/django-allauth-cas?branch=master + :target: https://coveralls.io/github/aureplop/django-allauth-cas?branch=master -**Warning:** Still under development. - CAS support for django-allauth_. -Supports: +Requirements + * Django 1.8 → 2.0 -- Django 1.8-10 - Python 2.7, 3.4-5 -- Django 1.11 - Python 2.7, 3.4-6 +Dependencies + * django-allauth_ + * python-cas_: CAS client library + +.. note:: + + Tests only target the latest allauth version compatible for each Django version + supported: + + * Django 1.9 with django-allauth 0.32.0; + * Django 1.8, 1.10, 1.11, 2.0 with the latest django-allauth. + +If you have any problems at use or think docs can be clearer, take a little +time to open an issue and/or a PR would be welcomed ;-) + +Acknowledgments + * This work is strongly inspired by the `OAuth2 support of django-allauth`_. -.. _django-allauth: https://www.intenct.nl/projects/django-allauth/ +************ +Installation +************ + +Install the python package ``django-allauth-cas``. For example, using pip: + +.. code-block:: bash + + $ pip install django-allauth-cas + +Add ``'allauth_cas'`` to ``INSTALLED_APPS``. + + +.. _django-allauth: https://github.com/pennersr/django-allauth +.. _OAuth2 support of django-allauth: https://github.com/pennersr/django-allauth/tree/master/allauth/socialaccount/providers/oauth2 +.. _python-cas: https://github.com/python-cas/python-cas diff --git a/allauth_cas/__init__.py b/allauth_cas/__init__.py index 02467c2..eddc85b 100644 --- a/allauth_cas/__init__.py +++ b/allauth_cas/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -__version__ = '0.0.1.dev5' +__version__ = '1.0.0b1' default_app_config = 'allauth_cas.apps.CASAccountConfig' diff --git a/allauth_cas/providers.py b/allauth_cas/providers.py index cf5ec52..a46b4f1 100644 --- a/allauth_cas/providers.py +++ b/allauth_cas/providers.py @@ -3,7 +3,9 @@ from six.moves.urllib.parse import parse_qsl import django from django.contrib import messages +from django.template.loader import render_to_string from django.utils.http import urlencode +from django.utils.safestring import mark_safe from allauth.socialaccount.providers.base import Provider @@ -15,41 +17,197 @@ else: class CASProvider(Provider): + def get_auth_params(self, request, action): + settings = self.get_settings() + ret = dict(settings.get('AUTH_PARAMS', {})) + dynamic_auth_params = request.GET.get('auth_params') + if dynamic_auth_params: + ret.update(dict(parse_qsl(dynamic_auth_params))) + return ret + + ## + # Data extraction from CAS responses. + ## + + def extract_uid(self, data): + """Extract the user uid. + + Notes: + Each pair ``(provider_id, uid)`` is unique and related to a single + user. + + Args: + data (uid (str), extra (dict)): CAS response. Example: + ``('alice', {'name': 'Alice'})`` + + Returns: + str: Default to ``data[0]``, user identifier for the CAS server. + + """ + uid, _ = data + return uid + + def extract_common_fields(self, data): + """Extract the data to pass to `SOCIALACCOUNT_ADAPTER.populate_user()`. + + Args: + data (uid (str), extra (dict)): CAS response. Example: + ``('alice', {'name': 'Alice'})`` + + Returns: + dict: Default:: + + { + 'username': extra.get('username', uid), + 'email': extra.get('email'), + 'first_name': extra.get('first_name'), + 'last_name': extra.get('last_name'), + 'name': extra.get('name'), + } + + """ + uid, extra = data + return { + 'username': extra.get('username', uid), + 'email': extra.get('email'), + 'first_name': extra.get('first_name'), + 'last_name': extra.get('last_name'), + 'name': extra.get('name'), + } + + def extract_email_addresses(self, data): + """Extract the email addresses. + + Args: + data (uid (str), extra (dict)): CAS response. Example: + ``('alice', {'name': 'Alice'})`` + + Returns: + `list` of `EmailAddress`: By default, ``[]``. + + Example:: + + [ + EmailAddress( + email='user@domain.net', + verified=True, primary=True, + ), + EmailAddress( + email='alias@domain.net', + verified=True, primary=False, + ), + ] + + """ + return super(CASProvider, self).extract_email_addresses(data) + + def extract_extra_data(self, data): + """Extract the data to save to `SocialAccount.extra_data`. + + Args: + data (uid (str), extra (dict)): CAS response. Example: + ``('alice', {'name': 'Alice'})`` + + Returns: + dict: By default, ``data``. + """ + uid, extra = data + return dict(extra, uid=uid) + + ## + # Message to suggest users to logout of the CAS server. + ## + + def add_message_suggest_caslogout( + self, request, next_page=None, level=None, + ): + """Add a message with a link for the user to logout of the CAS server. + + It uses the template ``socialaccount/messages/suggest_caslogout.html``, + with the ``provider`` and the ``logout_url`` as context. + + Args: + request: The request to which the message is added. + next_page (optional): Added to the logout link for the CAS server + to redirect the user to this url. + Default: ``request.get_full_path()`` + level: The message level. Default: ``messages.INFO`` + + """ + if next_page is None: + next_page = request.get_full_path() + if level is None: + level = messages.INFO + + logout_url = self.get_logout_url(request, next=next_page) + + # DefaultAccountAdapter.add_message is unusable because it always + # escape the message content. + + template = 'socialaccount/messages/suggest_caslogout.html' + context = { + 'provider': self, + 'logout_url': logout_url, + } + + messages.add_message( + request, level, + mark_safe(render_to_string(template, context).strip()), + fail_silently=True, + ) + + def message_suggest_caslogout_on_logout(self, request): + """Indicates whether the logout message should be sent on user logout. + + By default, it returns + ``settings.SOCIALACCOUNT_PROVIDERS[self.id]['MESSAGE_SUGGEST_CASLOGOUT_ON_LOGOUT']`` + or ``False``. + + Notes: + The ``request`` argument is the one trigerring the emission of the + signal ``user_logged_out``. + + """ + return ( + self.get_settings() + .get('MESSAGE_SUGGEST_CASLOGOUT_ON_LOGOUT', False) + ) + + def message_suggest_caslogout_on_logout_level(self, request): + """Level of the logout message issued on user logout. + + By default, it returns + ``settings.SOCIALACCOUNT_PROVIDERS[self.id]['MESSAGE_SUGGEST_CASLOGOUT_ON_LOGOUT_LEVEL']`` + or ``messages.INFO``. + + Notes: + The ``request`` argument is the one trigerring the emission of the + signal ``user_logged_out``. + + """ + return ( + self.get_settings() + .get('MESSAGE_SUGGEST_CASLOGOUT_ON_LOGOUT_LEVEL', messages.INFO) + ) + + ## + # Shortcuts functions. + ## + def get_login_url(self, request, **kwargs): url = reverse(self.id + '_login') if kwargs: url += '?' + urlencode(kwargs) return url + def get_callback_url(self, request, **kwargs): + url = reverse(self.id + '_callback') + if kwargs: + url += '?' + urlencode(kwargs) + return url + def get_logout_url(self, request, **kwargs): url = reverse(self.id + '_logout') if kwargs: url += '?' + urlencode(kwargs) return url - - def get_auth_params(self, request, action): - settings = self.get_settings() - ret = dict(settings.get('AUTH_PARAMS', {})) - dynamic_auth_params = request.GET.get('auth_params') - if dynamic_auth_params: - ret.update(dict(parse_qsl(dynamic_auth_params))) - return ret - - def message_on_logout(self, request): - return self.get_settings().get('MESSAGE_ON_LOGOUT', True) - - def message_on_logout_level(self, request): - return self.get_settings().get('MESSAGE_ON_LOGOUT_LEVEL', - messages.INFO) - - def extract_uid(self, data): - username, _, _ = data - return username - - def extract_common_fields(self, data): - username, _, _ = data - return {'username': username} - - def extract_extra_data(self, data): - _, extra_data, _ = data - return extra_data diff --git a/allauth_cas/signals.py b/allauth_cas/signals.py index 208ee37..927cbfd 100644 --- a/allauth_cas/signals.py +++ b/allauth_cas/signals.py @@ -1,11 +1,8 @@ # -*- coding: utf-8 -*- -from django.conf import settings -from django.contrib import messages from django.contrib.auth.signals import user_logged_out from django.dispatch import receiver -from django.template.loader import render_to_string -from django.utils.safestring import mark_safe +from allauth.account.adapter import get_adapter from allauth.account.utils import get_next_redirect_url from allauth.socialaccount import providers @@ -16,35 +13,20 @@ from . import CAS_PROVIDER_SESSION_KEY def cas_account_logout(sender, request, **kwargs): provider_id = request.session.get(CAS_PROVIDER_SESSION_KEY) - if (not provider_id or - 'django.contrib.messages' not in settings.INSTALLED_APPS): + if not provider_id: return provider = providers.registry.by_id(provider_id, request) - if not provider.message_on_logout(request): + if not provider.message_suggest_caslogout_on_logout(request): return - redirect_url = ( + next_page = ( get_next_redirect_url(request) or - request.get_full_path() + get_adapter(request).get_logout_redirect_url(request) ) - logout_kwargs = {'next': redirect_url} if redirect_url else {} - logout_url = provider.get_logout_url(request, **logout_kwargs) - logout_link = mark_safe('link'.format(logout_url)) - - level = provider.message_on_logout_level(request) - - # DefaultAccountAdapter.add_message from allauth.account.adapter is - # unusable because HTML in message content is always escaped. - - template = 'cas_account/messages/logged_out.txt' - context = { - 'logout_url': logout_url, - 'logout_link': logout_link, - } - - message = mark_safe(render_to_string(template, context).strip()) - - messages.add_message(request, level, message) + provider.add_message_suggest_caslogout( + request, next_page=next_page, + level=provider.message_suggest_caslogout_on_logout_level(request), + ) diff --git a/allauth_cas/templates/cas_account/messages/logged_out.txt b/allauth_cas/templates/cas_account/messages/logged_out.txt deleted file mode 100644 index b0b88ab..0000000 --- a/allauth_cas/templates/cas_account/messages/logged_out.txt +++ /dev/null @@ -1,4 +0,0 @@ -{% load i18n %} -{% blocktrans %} -To logout of CAS, please close your browser, or visit this {{ logout_link }}. -{% endblocktrans %} diff --git a/allauth_cas/templates/socialaccount/messages/suggest_caslogout.html b/allauth_cas/templates/socialaccount/messages/suggest_caslogout.html new file mode 100644 index 0000000..1d0662d --- /dev/null +++ b/allauth_cas/templates/socialaccount/messages/suggest_caslogout.html @@ -0,0 +1,5 @@ +{% load i18n %} + +{% blocktrans with provider_name=provider.name %} +To logout of {{ provider_name }}, please close your browser, or visit this link. +{% endblocktrans %} diff --git a/allauth_cas/urls.py b/allauth_cas/urls.py index be30316..687dca6 100644 --- a/allauth_cas/urls.py +++ b/allauth_cas/urls.py @@ -1,23 +1,51 @@ # -*- coding: utf-8 -*- from django.conf.urls import include, url - -from allauth.utils import import_attribute +from django.utils.module_loading import import_string def default_urlpatterns(provider): package = provider.get_package() - login_view = import_attribute(package + '.views.login') - callback_view = import_attribute(package + '.views.callback') - logout_view = import_attribute(package + '.views.logout') + try: + login_view = import_string(package + '.views.login') + except ImportError: + raise ImportError( + "The login view for the '{id}' provider is lacking from the " + "'views' module of its app.\n" + "You may want to add:\n" + "from allauth_cas.views import CASLoginView\n\n" + "login = CASLoginView.adapter_view()" + .format(id=provider.id) + ) + + try: + callback_view = import_string(package + '.views.callback') + except ImportError: + raise ImportError( + "The callback view for the '{id}' provider is lacking from the " + "'views' module of its app.\n" + "You may want to add:\n" + "from allauth_cas.views import CASCallbackView\n\n" + "callback = CASCallbackView.adapter_view()" + .format(id=provider.id) + ) + + try: + logout_view = import_string(package + '.views.logout') + except ImportError: + logout_view = None urlpatterns = [ - url('^login/$', - login_view, name=provider.id + '_login'), - url('^login/callback/$', - callback_view, name=provider.id + '_callback'), - url('^logout/$', - logout_view, name=provider.id + '_logout'), + url('^login/$', login_view, + name=provider.id + '_login'), + url('^login/callback/$', callback_view, + name=provider.id + '_callback'), ] + if logout_view is not None: + urlpatterns += [ + url('^logout/$', logout_view, + name=provider.id + '_logout'), + ] + return [url('^' + provider.get_slug() + '/', include(urlpatterns))] diff --git a/allauth_cas/views.py b/allauth_cas/views.py index e9a3685..d2ea9b7 100644 --- a/allauth_cas/views.py +++ b/allauth_cas/views.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- -import django from django.http import HttpResponseRedirect -from django.utils.http import urlencode +from django.utils.functional import cached_property from allauth.account.adapter import get_adapter from allauth.account.utils import get_next_redirect_url @@ -16,11 +15,6 @@ import cas from . import CAS_PROVIDER_SESSION_KEY from .exceptions import CASAuthenticationError -if django.VERSION >= (1, 10): - from django.urls import reverse -else: - from django.core.urlresolvers import reverse - class AuthAction(object): AUTHENTICATE = 'authenticate' @@ -29,23 +23,38 @@ class AuthAction(object): class CASAdapter(object): + #: CAS server url. + url = None + #: CAS server version. + #: Choices: ``1`` or ``'1'``, ``2`` or ``'2'``, ``3`` or ``'3'``, + #: ``'CAS_2_SAML_1_0'`` + version = None def __init__(self, request): self.request = request - @property + @cached_property def renew(self): - """ - If user is already authenticated on Django, he may already been - connected to CAS, but still may want to use another CAS account. - We set renew to True in this case, as the CAS server won't use the - single sign-on. - To specifically check, if the current user has used a CAS server, - we check if the CAS session key is set. + """Controls presence of ``renew`` in requests to the CAS server. + + If ``True``, opt out single sign-on (SSO) functionality of the CAS + server. So that, user is always prompted for his username and password. + + If ``False``, the CAS server does not prompt users for their + credentials if a SSO exists. + + The default allows user to connect via an already used CAS server + with other credentials. + + Returns: + ``True`` if logged in user has already connected to Django using + **any** CAS provider in the current session, ``False`` otherwise. + """ return CAS_PROVIDER_SESSION_KEY in self.request.session - def get_provider(self): + @cached_property + def provider(self): """ Returns a provider instance for the current request. """ @@ -53,75 +62,66 @@ class CASAdapter(object): def complete_login(self, request, response): """ - Executed by the callback view after successful authentication on CAS - server. + Executed by the callback view after successful authentication on the + CAS server. + + Args: + request + response (`dict`): Data returned by the CAS server. + ``response[username]`` contains the user identifier for the + server, and may contain extra user-attributes. + + Returns: + `SocialLogin()` object: State of the login-session. - Returns the SocialLogin object which represents the state of the - current login-session. """ - login = (self.get_provider() - .sociallogin_from_response(request, response)) + login = self.provider.sociallogin_from_response(request, response) return login def get_service_url(self, request): - """ - Returns the service url to for a CAS client. + """The service url, used by the CAS client. - From CAS specification, the service url is used in order to redirect - user after a successful login on CAS server. Also, service_url sent - when ticket is verified must be the one for which ticket was issued. + According to the CAS spec, the service url is passed by the CAS client + at several times. It must be the same for all interactions with the CAS + server. - To conform this, the service url is always the callback url. + It is used as redirection from the CAS server after a succssful + authentication. So, the callback url is used as service url. - A redirect url is found from the current request and appended as - parameter to the service url and is latter used by the callback view to - redirect user. + If present, the GET param ``next`` is added to the service url. """ redirect_to = get_next_redirect_url(request) callback_kwargs = {'next': redirect_to} if redirect_to else {} - callback_url = self.get_callback_url(request, **callback_kwargs) + callback_url = ( + self.provider.get_callback_url(request, **callback_kwargs)) service_url = request.build_absolute_uri(callback_url) return service_url - def get_callback_url(self, request, **kwargs): - """ - Returns the callback url of the provider. - - Keyword arguments are set as query string. - """ - url = reverse(self.provider_id + '_callback') - if kwargs: - url += '?' + urlencode(kwargs) - return url - class CASView(object): - + """ + Base class for CAS views. + """ @classmethod - def adapter_view(cls, adapter, **kwargs): - """ - Similar to the Django as_view() method. + def adapter_view(cls, adapter): + """Transform the view class into a view function. - It also setups a few things: - - given adapter argument will be used in views internals. - - if the view execution raises a CASAuthenticationError, the view - renders an authentication error page. + Similar to the Django ``as_view()`` method. - To use this: + Notes: + An (human) error page is rendered if any ``CASAuthenticationError`` + is catched. - - subclass CAS adapter as wanted: + Args: + adapter (:class:`CASAdapter`): Provide specifics of a CAS server. - class MyAdapter(CASAdapter): - url = 'https://my.cas.url' + Returns: + A view function. The given adapter and related provider are + accessible as attributes from the view class. - - define views: - - login = views.CASLoginView.adapter_view(MyAdapter) - callback = views.CASCallbackView.adapter_view(MyAdapter) - logout = views.CASLogoutView.adapter_view(MyAdapter) """ def view(request, *args, **kwargs): @@ -134,7 +134,7 @@ class CASView(object): # Setup and store adapter as view attribute. self.adapter = adapter(request) - self.provider = self.adapter.get_provider() + self.provider = self.adapter.provider try: return self.dispatch(request, *args, **kwargs) @@ -207,17 +207,20 @@ class CASCallbackView(CASView): # - error: None, {}, None response = client.verify_ticket(ticket) - if not response[0]: + uid, extra, _ = response + + if not uid: raise CASAuthenticationError( "CAS server doesn't validate the ticket." ) - # The CAS provider in use is stored to propose to the user to - # disconnect from the latter when he logouts. + # Keep tracks of the last used CAS provider. request.session[CAS_PROVIDER_SESSION_KEY] = self.provider.id - # Finish the login flow - login = self.adapter.complete_login(request, response) + data = (uid, extra) + + # Finish the login flow. + login = self.adapter.complete_login(request, data) login.state = SocialLogin.unstash_state(request) return complete_social_login(request, login) @@ -242,7 +245,7 @@ class CASLogoutView(CASView): def get_redirect_url(self): """ - Returns the url to redirect after logout from current request. + Returns the url to redirect after logout. """ request = self.request return ( diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..69fa449 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1 @@ +_build/ diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d363d4f --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SPHINXPROJ = django-allauth-cas +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/docs/README b/docs/README new file mode 100644 index 0000000..5a76931 --- /dev/null +++ b/docs/README @@ -0,0 +1,14 @@ +############# +Documentation +############# + + +The documentation is compiled from reStructuredText using `Sphinx`_. + +To compile your own html version in ``_build/html/``:: + + # First time only. + pip install sphinx + + # Build html. + make html diff --git a/docs/advanced/cas_client.txt b/docs/advanced/cas_client.txt new file mode 100644 index 0000000..ad0f2ae --- /dev/null +++ b/docs/advanced/cas_client.txt @@ -0,0 +1,40 @@ +######################## +Configure the CAS client +######################## + +.. seealso:: + + `CAS Protocol Specification`_ + +The CAS client parameters can be set on the ``CASAdapter`` subclasses. + + +****************** +Server information +****************** + +You must at least fill these attributes on an adapter class. + +.. autoattribute:: allauth_cas.views.CASAdapter.url + +.. autoattribute:: allauth_cas.views.CASAdapter.version + + +***************** +Client parameters +***************** + +.. autoattribute:: allauth_cas.views.CASAdapter.renew + +.. note:: + + A SSO session is created when user successfully authenticates against the + server, which let an HTTP cookie in the browser current session. If SSO is + enabled (``renew = False``), server checks this cookie, if any, to bypass the + request of user credentials. Depending on the server configuration and user + input at login time, CAS server replies to login page requests with a warning + page, or transparently redirects to the callback url (path to come back to + your web service). + + +.. _`CAS Protocol Specification`: https://apereo.github.io/cas/5.0.x/protocol/CAS-Protocol-Specification.html diff --git a/docs/advanced/extract_data.txt b/docs/advanced/extract_data.txt new file mode 100644 index 0000000..14b6cdf --- /dev/null +++ b/docs/advanced/extract_data.txt @@ -0,0 +1,21 @@ +################################# +Use data returned by a CAS server +################################# + +.. seealso:: + + `Creating and Populating User instances`_ + +The following methods of ``CASProvider`` are used to extract data from the CAS +responses. + +.. automethod:: allauth_cas.providers.CASProvider.extract_uid + +.. automethod:: allauth_cas.providers.CASProvider.extract_common_fields + +.. automethod:: allauth_cas.providers.CASProvider.extract_email_addresses + +.. automethod:: allauth_cas.providers.CASProvider.extract_extra_data + + +.. _`Creating and Populating User instances`: http://django-allauth.readthedocs.io/en/latest/advanced.html#creating-and-populating-user-instances diff --git a/docs/advanced/index.txt b/docs/advanced/index.txt new file mode 100644 index 0000000..f6abaa2 --- /dev/null +++ b/docs/advanced/index.txt @@ -0,0 +1,8 @@ +############## +Advanced Usage +############## + +.. toctree:: + cas_client + extract_data + signout diff --git a/docs/advanced/signout.txt b/docs/advanced/signout.txt new file mode 100644 index 0000000..b6ab530 --- /dev/null +++ b/docs/advanced/signout.txt @@ -0,0 +1,87 @@ +################ +Sign out helpers +################ + +To use features described on this page, you must also add a logout view for +your provider: + +.. code-block:: python + + from allauth_cas.views import CASLogoutView + + logout = CASLogoutView.adapter_view(MyCASAdapter) + + +************** +Suggest logout +************** + +Sending message +=============== + +Using the method below, you can emit a message to suggest users to logout of +the CAS server. + +.. automethod:: allauth_cas.providers.CASProvider.add_message_suggest_caslogout + +Sending message at user logout +============================== + +When the user signs out your application, this message can be sent +automatically using the following settings. + +The message contains a logout link for **the last used** CAS server during the +session. + +In your settings: + +.. code-block:: python + + SOCIALACCOUNT_PROVIDERS = { + # … + '': { + # … + + 'MESSAGE_SUGGEST_CASLOGOUT_ON_LOGOUT': True, + + # Optional. By default, messages.INFO + 'MESSAGE_SUGGEST_CASLOGOUT_ON_LOGOUT_LEVEL': messages.WARNING, + }, + } + +If you need more control over the sending of the message, you can use the +methods below of the provider class. + +.. automethod:: allauth_cas.providers.CASProvider.message_suggest_caslogout_on_logout + +.. automethod:: allauth_cas.providers.CASProvider.message_suggest_caslogout_on_logout_level + + +**************************** +Redirection after CAS logout +**************************** + +An url is always given for the CAS server to redirect the user to your +application. + +The target of this redirection is: + + * If the link is created on user logout (using above configuration): + + * if present, the url pointed by the GET parameter ``next``, which should + be the url the user has just landed after being logged out; + * otherwise, the value returned by + ``ACCOUNT_ADAPTER.get_logout_redirect_url()``. + + * If the link is created using + :meth:`~allauth_cas.providers.CASProvider.add_message_suggest_caslogout`: + + * if present, the value of the parameter ``next_page``; + * otherwise, the url of the current page. + + * Otherwise, ``ACCOUNT_ADAPTER.get_logout_redirect_url()``. + +.. note:: + + If no redirection happens, you should check the version declared by the + ``CASAdapter`` class corresponds to the CAS server one. diff --git a/docs/basic_setup.txt b/docs/basic_setup.txt new file mode 100644 index 0000000..bea9060 --- /dev/null +++ b/docs/basic_setup.txt @@ -0,0 +1,153 @@ +########### +Basic setup +########### + +Following the instructions on this page, your will create a provider for +allauth, which allows users to connect through a CAS server. + + +**************** +1. Create an app +**************** + +``allauth`` determines available providers by scanning ``INSTALLED_APPS``. +Let's begin by creating an app for the CAS provider: + +.. code-block:: bash + + $ python manage.py startapp mycas + +And add it to the ``INSTALLED_APPS``: + +.. code-block:: python + + INSTALLED_APPS = [ + # … + 'allauth', + 'allauth.account', + 'allauth.socialaccount', + + 'allauth_cas', + + 'mycas', + ] + + +********************** +2. Create the provider +********************** + +In ``mycas/provider.py``, create subclasses of ``ProviderAccount`` and +``CASProvider``. + +The ``CASProvider`` subclass defines how to process data returned by the CAS +server. + +.. code-block:: python + + from allauth.socialaccount.providers.base import ProviderAccount + from allauth_cas.providers import CASProvider + + + class MyCASAccount(ProviderAccount): + pass + + + class MyCASProvider(CASProvider): + id = 'mycas' # Choose an identifier for your provider + name = 'My CAS' # Verbose name of your provider + account_class = MyCASAccount + + + provider_classes = [ClipperProvider] + +.. seealso:: + + :doc:`advanced/extract_data` + + +******************* +3. Create the views +******************* + +Subclass ``CASAdapter`` to give your configuration as a CAS client. + +.. code-block:: python + + from allauth_cas.views import CASAdapter + + from .providers import MyCASProvider + + + class MyCASAdapter(CASAdapter): + provider_id = MyCASProvider.id + url = 'https://mycas.mydomain.net' # The CAS server url + version = 3 # Select the CAS protocol version used by the CAS server: 1, 2, 3… + +Then, you can simply create the login and callback views. + +.. code-block:: python + + from allauth_cas.views import CASCallbackView, CASLoginView + + login = CASLoginView.adapter_view(MyCASAdapter) + callback = CASLogoutView.adapter_view(MyCASAdapter) + +.. seealso:: + + :doc:`advanced/cas_client` + + +****************** +4. Create the urls +****************** + +Finally, add the urls in ``mycas/urls.py``. + +.. code-block:: python + + from allauth_cas.urls import default_urlpatterns + + from .provider import MyCASProvider + + urlpatterns = default_urlpatterns(MyCasProvider) + +There is no need to do more, as ``allauth`` is responsible for including these +urls. + + +******************************************* +5. Allow your application at the CAS server +******************************************* + +.. note:: + + This step is only required if the CAS server restricts access to known + applications. + +CAS servers may restrict their usage to a list of known clients. To do so, +the service url must be known by the CAS server. For our case, the service +url is the callback url of a CAS provider. + +The service url is formatted as: + +.. code-block:: none + + ///login/callback/ + +Assuming a site is served at ``https://mydomain.net``, that the allauth urls +are included under ``accounts/``, and the provider id is ``mycas``, the service url +is: + +.. code-block:: none + + https://mydomain.net/accounts/mycas/login/callback + +While in local development, it can be: + +.. code-block:: none + + http://127.0.0.1:8000/accounts/mycas/login/callback + +This url should be added to the authorized services within the CAS server +configuration (by yourself or someone in charge of the server). diff --git a/docs/changelog.txt b/docs/changelog.txt new file mode 100644 index 0000000..0d526c7 --- /dev/null +++ b/docs/changelog.txt @@ -0,0 +1,5 @@ +######### +Changelog +######### + +.. include:: ../CHANGELOG.rst diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..5a4587e --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# django-allauth-cas documentation build configuration file. +# +# 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. + +import os +import sys + +import django + +from allauth_cas import __version__ + +# 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. + +sys.path.insert(0, os.path.abspath('../')) + +# Setup django to avoid issues with autodoc. + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings') + +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.napoleon', + 'sphinx.ext.todo', + 'sphinx.ext.viewcode', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.txt' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = 'django-allauth-cas' +copyright = '2017, Aurélien Delobelle' +author = 'Aurélien Delobelle' + +# 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 = __version__ +# The full version, including alpha/beta/rc tags. +release = __version__ + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# 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 name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = True + + +# -- 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 = '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 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'] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# This is required for the alabaster theme +# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars +html_sidebars = { + '**': [ + 'relations.html', # needs 'show_related': True theme option to display + 'searchbox.html', + ] +} + + +# -- Options for HTMLHelp output ------------------------------------------ + +# Output file base name for HTML help builder. +htmlhelp_basename = 'django-allauth-casdoc' + + +# -- 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-allauth-cas.tex', 'django-allauth-cas Documentation', + 'Aurélien Delobelle', 'manual'), +] + + +# -- 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-allauth-cas', 'django-allauth-cas Documentation', + [author], 1) +] + + +# -- 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-allauth-cas', 'django-allauth-cas Documentation', + author, 'django-allauth-cas', 'One line description of project.', + 'Miscellaneous'), +] diff --git a/docs/index.txt b/docs/index.txt new file mode 100644 index 0000000..6fd5b85 --- /dev/null +++ b/docs/index.txt @@ -0,0 +1,16 @@ +.. django-allauth-cas documentation master file. + +.. include:: ../README.rst + + +******** +Contents +******** + +.. toctree:: + :maxdepth: 2 + + basic_setup + advanced/index + + changelog diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..260f274 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,36 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build +set SPHINXPROJ=django-allauth-cas + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 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 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +:end +popd diff --git a/setup.py b/setup.py index bd84887..b256fac 100644 --- a/setup.py +++ b/setup.py @@ -20,13 +20,14 @@ setup( long_description=README, url='https://github.com/aureplop/django-allauth-cas', classifiers=[ - 'Development Status :: 3 - Alpha', + 'Development Status :: 4 - Beta', 'Environment :: Web Environment', 'Framework :: Django', 'Framework :: Django :: 1.8', 'Framework :: Django :: 1.9', 'Framework :: Django :: 1.10', 'Framework :: Django :: 1.11', + 'Framework :: Django :: 2.0', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', @@ -48,6 +49,7 @@ setup( 'six', ], extras_require={ + 'docs': ['sphinx'], 'tests': ['tox'], }, ) diff --git a/tests/test_flows.py b/tests/test_flows.py index 3c6dc4b..fb9fdbd 100644 --- a/tests/test_flows.py +++ b/tests/test_flows.py @@ -12,8 +12,8 @@ User = get_user_model() class LogoutFlowTests(CASTestCase): expected_msg_str = ( - "To logout of CAS, please close your browser, or visit this " - "" + "To logout of The Provider, please close your browser, or visit this " + "" "link." ) @@ -28,43 +28,36 @@ class LogoutFlowTests(CASTestCase): ) self.assertTemplateNotUsed( response, - 'cas_account/messages/logged_out.txt', + 'socialaccount/messages/suggest_caslogout.html', ) @override_settings(SOCIALACCOUNT_PROVIDERS={ 'theid': { - 'MESSAGE_ON_LOGOUT': True, - 'MESSAGE_ON_LOGOUT_LEVEL': messages.WARNING, + 'MESSAGE_SUGGEST_CASLOGOUT_ON_LOGOUT': True, + 'MESSAGE_SUGGEST_CASLOGOUT_ON_LOGOUT_LEVEL': messages.WARNING, }, }) def test_message_on_logout(self): """ Message is sent to propose user to logout of CAS. """ - r = self.client.post('/accounts/logout/') + r = self.client.post('/accounts/logout/?next=/redir/') r_messages = get_messages(r.wsgi_request) expected_msg = Message(messages.WARNING, self.expected_msg_str) self.assertIn(expected_msg, r_messages) - self.assertTemplateUsed(r, 'cas_account/messages/logged_out.txt') + self.assertTemplateUsed( + r, 'socialaccount/messages/suggest_caslogout.html') - @override_settings(SOCIALACCOUNT_PROVIDERS={ - 'theid': { - 'MESSAGE_ON_LOGOUT': False, - }, - }) def test_message_on_logout_disabled(self): - """ - The logout message can be disabled in settings. - """ r = self.client.post('/accounts/logout/') self.assertCASLogoutNotInMessages(r) @override_settings(SOCIALACCOUNT_PROVIDERS={ - 'theid': {'MESSAGE_ON_LOGOUT': True}, + 'theid': {'MESSAGE_SUGGEST_CASLOGOUT_ON_LOGOUT': True}, }) - def test_default_logout(self): + def test_other_logout(self): """ The CAS logout message doesn't appear with other login methods. """ diff --git a/tests/test_providers.py b/tests/test_providers.py index abe7bb2..ef68bb7 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -1,5 +1,11 @@ # -*- coding: utf-8 -*- +from six.moves.urllib.parse import urlencode + from django.contrib import messages +from django.contrib.messages.api import get_messages +from django.contrib.messages.middleware import MessageMiddleware +from django.contrib.messages.storage.base import Message +from django.contrib.sessions.middleware import SessionMiddleware from django.test import RequestFactory, TestCase, override_settings from allauth.socialaccount.providers import registry @@ -12,12 +18,14 @@ from .example.provider import ExampleCASProvider class CASProviderTests(TestCase): def setUp(self): - factory = RequestFactory() - request = factory.get('/test/') - request.session = {} - self.request = request + self.request = self._get_request() + self.provider = ExampleCASProvider(self.request) - self.provider = ExampleCASProvider(request) + def _get_request(self): + request = RequestFactory().get('/test/') + SessionMiddleware().process_request(request) + MessageMiddleware().process_request(request) + return request def test_register(self): """ @@ -26,10 +34,6 @@ class CASProviderTests(TestCase): self.assertIsInstance(registry.by_id('theid'), ExampleCASProvider) def test_get_login_url(self): - """ - get_login_url returns the url to logout of the provider. - Keyword arguments are set as query string. - """ url = self.provider.get_login_url(self.request) self.assertEqual('/accounts/theid/login/', url) @@ -43,11 +47,21 @@ class CASProviderTests(TestCase): 'Dwhoam%25C3%25AF' ) + def test_get_callback_url(self): + url = self.provider.get_callback_url(self.request) + self.assertEqual('/accounts/theid/login/callback/', url) + + url_with_qs = self.provider.get_callback_url( + self.request, + next='/path?quéry=string&two=whoam%C3%AF', + ) + self.assertEqual( + url_with_qs, + '/accounts/theid/login/callback/?next=%2Fpath%3Fqu%C3%A9ry%3Dstrin' + 'g%26two%3Dwhoam%25C3%25AF' + ) + def test_get_logout_url(self): - """ - get_logout_url returns the url to logout of the provider. - Keyword arguments are set as query string. - """ url = self.provider.get_logout_url(self.request) self.assertEqual('/accounts/theid/logout/', url) @@ -97,35 +111,90 @@ class CASProviderTests(TestCase): 'next': 'two=whoam%C3%AF&qu%C3%A9ry=string', }) + def test_add_message_suggest_caslogout(self): + expected_msg_base_str = ( + "To logout of The Provider, please close your browser, or visit " + "this link." + ) + + # Defaults. + req1 = self.request + + self.provider.add_message_suggest_caslogout(req1) + + expected_msg1 = Message( + messages.INFO, + expected_msg_base_str.format(urlencode({'next': '/test/'})), + ) + self.assertIn(expected_msg1, get_messages(req1)) + + # Custom arguments. + req2 = self._get_request() + + self.provider.add_message_suggest_caslogout( + req2, next_page='/redir/', level=messages.WARNING) + + expected_msg2 = Message( + messages.WARNING, + expected_msg_base_str.format(urlencode({'next': '/redir/'})), + ) + self.assertIn(expected_msg2, get_messages(req2)) + + def test_message_suggest_caslogout_on_logout(self): + self.assertFalse( + self.provider.message_suggest_caslogout_on_logout(self.request)) + + with override_settings(SOCIALACCOUNT_PROVIDERS={ + 'theid': {'MESSAGE_SUGGEST_CASLOGOUT_ON_LOGOUT': True}, + }): + self.assertTrue( + self.provider + .message_suggest_caslogout_on_logout(self.request) + ) + @override_settings(SOCIALACCOUNT_PROVIDERS={ 'theid': { - 'MESSAGE_ON_LOGOUT_LEVEL': messages.WARNING, + 'MESSAGE_SUGGEST_CASLOGOUT_ON_LOGOUT_LEVEL': messages.WARNING, }, }) - def test_message_on_logout(self): - message_on_logout = self.provider.message_on_logout(self.request) - self.assertTrue(message_on_logout) - - message_level = self.provider.message_on_logout_level(self.request) - self.assertEqual(messages.WARNING, message_level) + def test_message_suggest_caslogout_on_logout_level(self): + self.assertEqual(messages.WARNING, ( + self.provider + .message_suggest_caslogout_on_logout_level(self.request) + )) def test_extract_uid(self): - response = 'useRName', {}, None + response = 'useRName', {} uid = self.provider.extract_uid(response) self.assertEqual('useRName', uid) def test_extract_common_fields(self): - response = 'useRName', {}, None + response = 'useRName', {} common_fields = self.provider.extract_common_fields(response) self.assertDictEqual(common_fields, { 'username': 'useRName', + 'first_name': None, + 'last_name': None, + 'name': None, + 'email': None, + }) + + def test_extract_common_fields_with_extra(self): + response = 'useRName', {'username': 'user', 'email': 'user@mail.net'} + common_fields = self.provider.extract_common_fields(response) + self.assertDictEqual(common_fields, { + 'username': 'user', + 'first_name': None, + 'last_name': None, + 'name': None, + 'email': 'user@mail.net', }) def test_extract_extra_data(self): - attributes = {'user_attr': 'thevalue', 'another': 'value'} - response = 'useRName', attributes, None + response = 'useRName', {'user_attr': 'thevalue', 'another': 'value'} extra_data = self.provider.extract_extra_data(response) self.assertDictEqual(extra_data, { 'user_attr': 'thevalue', 'another': 'value', + 'uid': 'useRName', }) diff --git a/tests/test_views.py b/tests/test_views.py index dcc99af..ebae670 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -48,20 +48,6 @@ class CASAdapterTests(CASTestCase): service_url = adapter.get_service_url(request) self.assertEqual(expected, service_url) - def test_get_callback_url(self): - expected = '/accounts/theid/login/callback/' - callback_url = self.adapter.get_callback_url(self.request) - self.assertEqual(expected, callback_url) - - def test_get_callback_url_with_kwargs(self): - expected = ( - '/accounts/theid/login/callback/?next=%2Fpath%2F' - ) - callback_url = self.adapter.get_callback_url(self.request, **{ - 'next': '/path/', - }) - self.assertEqual(expected, callback_url) - def test_renew(self): """ From an anonymous request, renew is False to let using the single diff --git a/tox.ini b/tox.ini index 297f297..eeb6fdb 100644 --- a/tox.ini +++ b/tox.ini @@ -1,24 +1,29 @@ [tox] envlist = - django{18,19,110}-py{27,34,35}, + django18-py{27,34,35}, + django19-py{27,34,35}-allauth32, + django110-py{27,34,35}, django111-py{27,34,35,36}, django20-py{34,35,36}, + cov_combine, flake8, isort [testenv] deps = + allauth32: django-allauth>=0.32.0,<0.33.0 + django18: django>=1.8,<1.9 django19: django>=1.9,<1.10 django110: django>=1.10,<1.11 django111: django>=1.11,<2.0 django20: django>=2.0,<2.1 + coverage mock ; python_version < "3.0" usedevelop = True commands = - python -V coverage run \ --branch \ --source=allauth_cas --omit=*migrations* \