Beta release (with docs)

This commit is contained in:
Aurélien Delobelle 2017-12-29 18:19:01 +01:00
parent e3f75a0c9f
commit 5340ef0d1a
26 changed files with 1048 additions and 205 deletions

5
CHANGELOG.rst Normal file
View file

@ -0,0 +1,5 @@
******************
1.0.0 (unreleased)
******************
- First official release.

View file

@ -1,6 +1,6 @@
================== ##################
django-allauth-cas django-allauth-cas
================== ##################
.. image:: https://travis-ci.org/aureplop/django-allauth-cas.svg?branch=master .. 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
@ -9,14 +9,43 @@ django-allauth-cas
: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_. CAS support for django-allauth_.
Supports: Requirements
* Django 1.8 → 2.0
- Django 1.8-10 - Python 2.7, 3.4-5 Dependencies
- Django 1.11 - Python 2.7, 3.4-6 * 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

View file

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
__version__ = '0.0.1.dev5' __version__ = '1.0.0b1'
default_app_config = 'allauth_cas.apps.CASAccountConfig' default_app_config = 'allauth_cas.apps.CASAccountConfig'

View file

@ -3,7 +3,9 @@ from six.moves.urllib.parse import parse_qsl
import django import django
from django.contrib import messages from django.contrib import messages
from django.template.loader import render_to_string
from django.utils.http import urlencode from django.utils.http import urlencode
from django.utils.safestring import mark_safe
from allauth.socialaccount.providers.base import Provider from allauth.socialaccount.providers.base import Provider
@ -15,41 +17,197 @@ else:
class CASProvider(Provider): 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): def get_login_url(self, request, **kwargs):
url = reverse(self.id + '_login') url = reverse(self.id + '_login')
if kwargs: if kwargs:
url += '?' + urlencode(kwargs) url += '?' + urlencode(kwargs)
return url 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): def get_logout_url(self, request, **kwargs):
url = reverse(self.id + '_logout') url = reverse(self.id + '_logout')
if kwargs: if kwargs:
url += '?' + urlencode(kwargs) url += '?' + urlencode(kwargs)
return url 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

View file

@ -1,11 +1,8 @@
# -*- coding: utf-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.contrib.auth.signals import user_logged_out
from django.dispatch import receiver 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.account.utils import get_next_redirect_url
from allauth.socialaccount import providers from allauth.socialaccount import providers
@ -16,35 +13,20 @@ from . import CAS_PROVIDER_SESSION_KEY
def cas_account_logout(sender, request, **kwargs): def cas_account_logout(sender, request, **kwargs):
provider_id = request.session.get(CAS_PROVIDER_SESSION_KEY) provider_id = request.session.get(CAS_PROVIDER_SESSION_KEY)
if (not provider_id or if not provider_id:
'django.contrib.messages' not in settings.INSTALLED_APPS):
return return
provider = providers.registry.by_id(provider_id, request) 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 return
redirect_url = ( next_page = (
get_next_redirect_url(request) or 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 {} provider.add_message_suggest_caslogout(
logout_url = provider.get_logout_url(request, **logout_kwargs) request, next_page=next_page,
logout_link = mark_safe('<a href="{}">link</a>'.format(logout_url)) level=provider.message_suggest_caslogout_on_logout_level(request),
)
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)

View file

@ -1,4 +0,0 @@
{% load i18n %}
{% blocktrans %}
To logout of CAS, please close your browser, or visit this {{ logout_link }}.
{% endblocktrans %}

View file

@ -0,0 +1,5 @@
{% load i18n %}
{% blocktrans with provider_name=provider.name %}
To logout of {{ provider_name }}, please close your browser, or visit this <a href="{{ logout_url }}">link</a>.
{% endblocktrans %}

View file

@ -1,23 +1,51 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from django.conf.urls import include, url from django.conf.urls import include, url
from django.utils.module_loading import import_string
from allauth.utils import import_attribute
def default_urlpatterns(provider): def default_urlpatterns(provider):
package = provider.get_package() package = provider.get_package()
login_view = import_attribute(package + '.views.login') try:
callback_view = import_attribute(package + '.views.callback') login_view = import_string(package + '.views.login')
logout_view = import_attribute(package + '.views.logout') 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(<LocalCASAdapter>)"
.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(<LocalCASAdapter>)"
.format(id=provider.id)
)
try:
logout_view = import_string(package + '.views.logout')
except ImportError:
logout_view = None
urlpatterns = [ urlpatterns = [
url('^login/$', url('^login/$', login_view,
login_view, name=provider.id + '_login'), name=provider.id + '_login'),
url('^login/callback/$', url('^login/callback/$', callback_view,
callback_view, name=provider.id + '_callback'), name=provider.id + '_callback'),
url('^logout/$', ]
logout_view, name=provider.id + '_logout'),
if logout_view is not None:
urlpatterns += [
url('^logout/$', logout_view,
name=provider.id + '_logout'),
] ]
return [url('^' + provider.get_slug() + '/', include(urlpatterns))] return [url('^' + provider.get_slug() + '/', include(urlpatterns))]

View file

@ -1,7 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import django
from django.http import HttpResponseRedirect 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.adapter import get_adapter
from allauth.account.utils import get_next_redirect_url from allauth.account.utils import get_next_redirect_url
@ -16,11 +15,6 @@ import cas
from . import CAS_PROVIDER_SESSION_KEY from . import CAS_PROVIDER_SESSION_KEY
from .exceptions import CASAuthenticationError from .exceptions import CASAuthenticationError
if django.VERSION >= (1, 10):
from django.urls import reverse
else:
from django.core.urlresolvers import reverse
class AuthAction(object): class AuthAction(object):
AUTHENTICATE = 'authenticate' AUTHENTICATE = 'authenticate'
@ -29,23 +23,38 @@ class AuthAction(object):
class CASAdapter(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): def __init__(self, request):
self.request = request self.request = request
@property @cached_property
def renew(self): def renew(self):
""" """Controls presence of ``renew`` in requests to the CAS server.
If user is already authenticated on Django, he may already been
connected to CAS, but still may want to use another CAS account. If ``True``, opt out single sign-on (SSO) functionality of the CAS
We set renew to True in this case, as the CAS server won't use the server. So that, user is always prompted for his username and password.
single sign-on.
To specifically check, if the current user has used a CAS server, If ``False``, the CAS server does not prompt users for their
we check if the CAS session key is set. 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 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. Returns a provider instance for the current request.
""" """
@ -53,75 +62,66 @@ class CASAdapter(object):
def complete_login(self, request, response): def complete_login(self, request, response):
""" """
Executed by the callback view after successful authentication on CAS Executed by the callback view after successful authentication on the
server. 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() login = self.provider.sociallogin_from_response(request, response)
.sociallogin_from_response(request, response))
return login return login
def get_service_url(self, request): def get_service_url(self, request):
""" """The service url, used by the CAS client.
Returns the service url to for a CAS client.
From CAS specification, the service url is used in order to redirect According to the CAS spec, the service url is passed by the CAS client
user after a successful login on CAS server. Also, service_url sent at several times. It must be the same for all interactions with the CAS
when ticket is verified must be the one for which ticket was issued. 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 If present, the GET param ``next`` is added to the service url.
parameter to the service url and is latter used by the callback view to
redirect user.
""" """
redirect_to = get_next_redirect_url(request) redirect_to = get_next_redirect_url(request)
callback_kwargs = {'next': redirect_to} if redirect_to else {} 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) service_url = request.build_absolute_uri(callback_url)
return service_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): class CASView(object):
@classmethod
def adapter_view(cls, adapter, **kwargs):
""" """
Similar to the Django as_view() method. Base class for CAS views.
"""
@classmethod
def adapter_view(cls, adapter):
"""Transform the view class into a view function.
It also setups a few things: Similar to the Django ``as_view()`` method.
- given adapter argument will be used in views internals.
- if the view execution raises a CASAuthenticationError, the view
renders an authentication error page.
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): Returns:
url = 'https://my.cas.url' 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): def view(request, *args, **kwargs):
@ -134,7 +134,7 @@ class CASView(object):
# Setup and store adapter as view attribute. # Setup and store adapter as view attribute.
self.adapter = adapter(request) self.adapter = adapter(request)
self.provider = self.adapter.get_provider() self.provider = self.adapter.provider
try: try:
return self.dispatch(request, *args, **kwargs) return self.dispatch(request, *args, **kwargs)
@ -207,17 +207,20 @@ class CASCallbackView(CASView):
# - error: None, {}, None # - error: None, {}, None
response = client.verify_ticket(ticket) response = client.verify_ticket(ticket)
if not response[0]: uid, extra, _ = response
if not uid:
raise CASAuthenticationError( raise CASAuthenticationError(
"CAS server doesn't validate the ticket." "CAS server doesn't validate the ticket."
) )
# The CAS provider in use is stored to propose to the user to # Keep tracks of the last used CAS provider.
# disconnect from the latter when he logouts.
request.session[CAS_PROVIDER_SESSION_KEY] = self.provider.id request.session[CAS_PROVIDER_SESSION_KEY] = self.provider.id
# Finish the login flow data = (uid, extra)
login = self.adapter.complete_login(request, response)
# Finish the login flow.
login = self.adapter.complete_login(request, data)
login.state = SocialLogin.unstash_state(request) login.state = SocialLogin.unstash_state(request)
return complete_social_login(request, login) return complete_social_login(request, login)
@ -242,7 +245,7 @@ class CASLogoutView(CASView):
def get_redirect_url(self): 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 request = self.request
return ( return (

1
docs/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
_build/

20
docs/Makefile Normal file
View file

@ -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)

14
docs/README Normal file
View file

@ -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

View file

@ -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

View file

@ -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

8
docs/advanced/index.txt Normal file
View file

@ -0,0 +1,8 @@
##############
Advanced Usage
##############
.. toctree::
cas_client
extract_data
signout

87
docs/advanced/signout.txt Normal file
View file

@ -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 = {
# …
'<provider id>': {
# …
'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.

153
docs/basic_setup.txt Normal file
View file

@ -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
<url of your application>/<path to allauth urls>/<provider id>/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).

5
docs/changelog.txt Normal file
View file

@ -0,0 +1,5 @@
#########
Changelog
#########
.. include:: ../CHANGELOG.rst

181
docs/conf.py Normal file
View file

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

16
docs/index.txt Normal file
View file

@ -0,0 +1,16 @@
.. django-allauth-cas documentation master file.
.. include:: ../README.rst
********
Contents
********
.. toctree::
:maxdepth: 2
basic_setup
advanced/index
changelog

36
docs/make.bat Normal file
View file

@ -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

View file

@ -20,13 +20,14 @@ setup(
long_description=README, long_description=README,
url='https://github.com/aureplop/django-allauth-cas', url='https://github.com/aureplop/django-allauth-cas',
classifiers=[ classifiers=[
'Development Status :: 3 - Alpha', 'Development Status :: 4 - Beta',
'Environment :: Web Environment', 'Environment :: Web Environment',
'Framework :: Django', 'Framework :: Django',
'Framework :: Django :: 1.8', 'Framework :: Django :: 1.8',
'Framework :: Django :: 1.9', 'Framework :: Django :: 1.9',
'Framework :: Django :: 1.10', 'Framework :: Django :: 1.10',
'Framework :: Django :: 1.11', 'Framework :: Django :: 1.11',
'Framework :: Django :: 2.0',
'Intended Audience :: Developers', 'Intended Audience :: Developers',
'License :: OSI Approved :: MIT License', 'License :: OSI Approved :: MIT License',
'Operating System :: OS Independent', 'Operating System :: OS Independent',
@ -48,6 +49,7 @@ setup(
'six', 'six',
], ],
extras_require={ extras_require={
'docs': ['sphinx'],
'tests': ['tox'], 'tests': ['tox'],
}, },
) )

View file

@ -12,8 +12,8 @@ User = get_user_model()
class LogoutFlowTests(CASTestCase): class LogoutFlowTests(CASTestCase):
expected_msg_str = ( 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 "
"<a href=\"/accounts/theid/logout/?next=%2Faccounts%2Flogout%2F\">" "<a href=\"/accounts/theid/logout/?next=%2Fredir%2F\">"
"link</a>." "link</a>."
) )
@ -28,43 +28,36 @@ class LogoutFlowTests(CASTestCase):
) )
self.assertTemplateNotUsed( self.assertTemplateNotUsed(
response, response,
'cas_account/messages/logged_out.txt', 'socialaccount/messages/suggest_caslogout.html',
) )
@override_settings(SOCIALACCOUNT_PROVIDERS={ @override_settings(SOCIALACCOUNT_PROVIDERS={
'theid': { 'theid': {
'MESSAGE_ON_LOGOUT': True, 'MESSAGE_SUGGEST_CASLOGOUT_ON_LOGOUT': True,
'MESSAGE_ON_LOGOUT_LEVEL': messages.WARNING, 'MESSAGE_SUGGEST_CASLOGOUT_ON_LOGOUT_LEVEL': messages.WARNING,
}, },
}) })
def test_message_on_logout(self): def test_message_on_logout(self):
""" """
Message is sent to propose user to logout of CAS. 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) r_messages = get_messages(r.wsgi_request)
expected_msg = Message(messages.WARNING, self.expected_msg_str) expected_msg = Message(messages.WARNING, self.expected_msg_str)
self.assertIn(expected_msg, r_messages) 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): def test_message_on_logout_disabled(self):
"""
The logout message can be disabled in settings.
"""
r = self.client.post('/accounts/logout/') r = self.client.post('/accounts/logout/')
self.assertCASLogoutNotInMessages(r) self.assertCASLogoutNotInMessages(r)
@override_settings(SOCIALACCOUNT_PROVIDERS={ @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. The CAS logout message doesn't appear with other login methods.
""" """

View file

@ -1,5 +1,11 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from six.moves.urllib.parse import urlencode
from django.contrib import messages 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 django.test import RequestFactory, TestCase, override_settings
from allauth.socialaccount.providers import registry from allauth.socialaccount.providers import registry
@ -12,12 +18,14 @@ from .example.provider import ExampleCASProvider
class CASProviderTests(TestCase): class CASProviderTests(TestCase):
def setUp(self): def setUp(self):
factory = RequestFactory() self.request = self._get_request()
request = factory.get('/test/') self.provider = ExampleCASProvider(self.request)
request.session = {}
self.request = 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): def test_register(self):
""" """
@ -26,10 +34,6 @@ class CASProviderTests(TestCase):
self.assertIsInstance(registry.by_id('theid'), ExampleCASProvider) self.assertIsInstance(registry.by_id('theid'), ExampleCASProvider)
def test_get_login_url(self): 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) url = self.provider.get_login_url(self.request)
self.assertEqual('/accounts/theid/login/', url) self.assertEqual('/accounts/theid/login/', url)
@ -43,11 +47,21 @@ class CASProviderTests(TestCase):
'Dwhoam%25C3%25AF' '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): 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) url = self.provider.get_logout_url(self.request)
self.assertEqual('/accounts/theid/logout/', url) self.assertEqual('/accounts/theid/logout/', url)
@ -97,35 +111,90 @@ class CASProviderTests(TestCase):
'next': 'two=whoam%C3%AF&qu%C3%A9ry=string', '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 <a href=\"/accounts/theid/logout/?{}\">link</a>."
)
# 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={ @override_settings(SOCIALACCOUNT_PROVIDERS={
'theid': { 'theid': {
'MESSAGE_ON_LOGOUT_LEVEL': messages.WARNING, 'MESSAGE_SUGGEST_CASLOGOUT_ON_LOGOUT_LEVEL': messages.WARNING,
}, },
}) })
def test_message_on_logout(self): def test_message_suggest_caslogout_on_logout_level(self):
message_on_logout = self.provider.message_on_logout(self.request) self.assertEqual(messages.WARNING, (
self.assertTrue(message_on_logout) self.provider
.message_suggest_caslogout_on_logout_level(self.request)
message_level = self.provider.message_on_logout_level(self.request) ))
self.assertEqual(messages.WARNING, message_level)
def test_extract_uid(self): def test_extract_uid(self):
response = 'useRName', {}, None response = 'useRName', {}
uid = self.provider.extract_uid(response) uid = self.provider.extract_uid(response)
self.assertEqual('useRName', uid) self.assertEqual('useRName', uid)
def test_extract_common_fields(self): def test_extract_common_fields(self):
response = 'useRName', {}, None response = 'useRName', {}
common_fields = self.provider.extract_common_fields(response) common_fields = self.provider.extract_common_fields(response)
self.assertDictEqual(common_fields, { self.assertDictEqual(common_fields, {
'username': 'useRName', '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): def test_extract_extra_data(self):
attributes = {'user_attr': 'thevalue', 'another': 'value'} response = 'useRName', {'user_attr': 'thevalue', 'another': 'value'}
response = 'useRName', attributes, None
extra_data = self.provider.extract_extra_data(response) extra_data = self.provider.extract_extra_data(response)
self.assertDictEqual(extra_data, { self.assertDictEqual(extra_data, {
'user_attr': 'thevalue', 'user_attr': 'thevalue',
'another': 'value', 'another': 'value',
'uid': 'useRName',
}) })

View file

@ -48,20 +48,6 @@ class CASAdapterTests(CASTestCase):
service_url = adapter.get_service_url(request) service_url = adapter.get_service_url(request)
self.assertEqual(expected, service_url) 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): def test_renew(self):
""" """
From an anonymous request, renew is False to let using the single From an anonymous request, renew is False to let using the single

View file

@ -1,24 +1,29 @@
[tox] [tox]
envlist = 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}, django111-py{27,34,35,36},
django20-py{34,35,36}, django20-py{34,35,36},
cov_combine, cov_combine,
flake8, flake8,
isort isort
[testenv] [testenv]
deps = deps =
allauth32: django-allauth>=0.32.0,<0.33.0
django18: django>=1.8,<1.9 django18: django>=1.8,<1.9
django19: django>=1.9,<1.10 django19: django>=1.9,<1.10
django110: django>=1.10,<1.11 django110: django>=1.10,<1.11
django111: django>=1.11,<2.0 django111: django>=1.11,<2.0
django20: django>=2.0,<2.1 django20: django>=2.0,<2.1
coverage coverage
mock ; python_version < "3.0" mock ; python_version < "3.0"
usedevelop = True usedevelop = True
commands = commands =
python -V
coverage run \ coverage run \
--branch \ --branch \
--source=allauth_cas --omit=*migrations* \ --source=allauth_cas --omit=*migrations* \