deprecate plugin system based on pkg_resources (fixes #22865)
gitea/authentic/pipeline/head Build started... Details

This commit is contained in:
Benjamin Dauvergne 2018-11-22 13:32:11 +01:00
parent ed30de5b18
commit be7aafaad0
40 changed files with 548 additions and 667 deletions

View File

@ -1,2 +0,0 @@
authentic2-plugin-template is entirely under the copyright of Entr'ouvert and
distributed under the license AGPLv3 or later.

View File

@ -1,3 +0,0 @@
include COPYING
recursive-include src/authentic2_plugin_template/templates *.html
recursive-include src/authentic2_plugin_template/static *.js *.css *.png

View File

@ -1,20 +0,0 @@
** THIS IS A TEMPLATE PROJECT **
To rename it to your taste:
$ ./adapt.sh
** THIS IS A TEMPLATE PROJECT **
Authentic2 Plugin Template
==========================
Install
-------
You just have to install the package in your virtualenv and relaunch, it will
be automatically loaded by authentic2.
Settings
--------
** DESCRIBE CUSTOM SETTINGS HERE **

View File

@ -1,38 +0,0 @@
#!/bin/sh
set -x
echo "Give project name (it must match regexp ^[a-z][a-z0-9-]+$ )"
read PROJECT_NAME
if ! echo $PROJECT_NAME | grep -q '^[a-z][a-z0-9-]\+$'; then
echo "Invalid project name:" $PROJECT_NAME
exit 1
fi
UPPER_UNDERSCORED=`echo $PROJECT_NAME | tr a-z A-Z | sed 's/-/_/g'`
LOWER_UNDERSCORED=`echo $PROJECT_NAME | sed 's/-/_/g'`
TITLECASE=`echo $PROJECT_NAME | sed 's/-/ /g;s/.*/\L&/; s/[a-z]*/\u&/g'`
echo Project name: $PROJECT_NAME
echo Uppercase underscored: $UPPER_UNDERSCORED
echo Lowercase underscored: $LOWER_UNDERSCORED
echo Titlecase: $TITLECASE
if [ -d .git ]; then
MV='git mv'
else
MV=mv
fi
sed -i \
-e "s/authentic2_plugin_template/$LOWER_UNDERSCORED/g" \
-e "s/authentic2-plugin-template/$PROJECT_NAME/g" \
-e "s/A2_TEMPLATE_/A2_$UPPER_UNDERSCORED_/g" \
-e "s/Authentic2 Plugin Template/$TITLECASE/g" \
setup.py src/*/*.py README COPYING MANIFEST.in
$MV src/authentic2_plugin_template/static/authentic2_plugin_template \
src/authentic2_plugin_template/static/$LOWER_UNDERSCORED
$MV src/authentic2_plugin_template/templates/authentic2_plugin_template \
src/authentic2_plugin_template/templates/$LOWER_UNDERSCORED
$MV src/authentic2_plugin_template src/$LOWER_UNDERSCORED

View File

@ -1,55 +0,0 @@
#!/usr/bin/python
import subprocess
from setuptools import setup, find_packages
import os
def get_version():
'''Use the VERSION, if absent generates a version with git describe, if not
tag exists, take 0.0.0- and add the length of the commit log.
'''
if os.path.exists('VERSION'):
with open('VERSION', 'r') as v:
return v.read()
if os.path.exists('.git'):
p = subprocess.Popen(['git','describe','--dirty','--match=v*'],
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
result = p.communicate()[0]
if p.returncode == 0:
return result.split()[0][1:].replace('-', '.')
else:
return '0.0.0-%s' % len(
subprocess.check_output(
['git', 'rev-list', 'HEAD']).splitlines())
return '0.0.0'
README = file(os.path.join(
os.path.dirname(__file__),
'README')).read()
setup(name='authentic2-plugin-template',
version=get_version(),
license='AGPLv3',
description='Authentic2 Plugin Template',
long_description=README,
author="Entr'ouvert",
author_email="info@entrouvert.com",
packages=find_packages('src'),
package_dir={
'': 'src',
},
package_data={
'authentic2_plugin_template': [
'templates/authentic2_plugin_template/*.html',
'static/authentic2_plugin_template/js/*.js',
'static/authentic2_plugin_template/css/*.css',
'static/authentic2_plugin_template/img/*.png',
],
},
install_requires=[
],
entry_points={
'authentic2.plugin': [
'authentic2-plugin-template= authentic2_plugin_template:Plugin',
],
},
)

View File

@ -1,58 +0,0 @@
__version__ = '1.0.0'
class Plugin(object):
def get_before_urls(self):
from . import urls
return urls.urlpatterns
def get_after_urls(self):
return []
def get_apps(self):
return [__name__]
def get_before_middleware(self):
return []
def get_after_middleware(self):
return []
def get_authentication_backends(self):
return []
def get_auth_frontends(self):
return []
def get_idp_backends(self):
return []
def service_list(self, request):
'''For IdP plugins this method add links to the user homepage.
It must return a list of authentic2.utils.Service objects, each
object has a name and can have an url and some actions.
Service(name=name[, url=url[, actions=actions]])
Actions are a list of tuples, whose parts are
- first the name of the action,
- the HTTP method for calling the action,
- the URL for calling the action,
- the paramters to pass to this URL as a sequence of key-value tuples.
'''
return []
def logout_list(self, request):
'''For IdP or SP plugins this method add actions to logout from remote
IdP or SP.
It must returns a list of HTML fragments, each fragment is
responsible for calling the view doing the logout. Views are usually
called using <img/> or <iframge/> tags and finally redirect to an
icon indicating success or failure for the logout.
Authentic2 provide two such icons through the following URLs:
- os.path.join(settings.STATIC_URL, 'authentic2/img/ok.png')
- os.path.join(settings.STATIC_URL, 'authentic2/img/ok.png')
'''
return []

View File

@ -1,5 +0,0 @@
from django.contrib import admin
from . import models
# registrer your admin editable models here using admin.register

View File

@ -1,23 +0,0 @@
class AppSettings(object):
__DEFAULTS = {
'ENABLE': True,
}
def __init__(self, prefix):
self.prefix = prefix
def _setting(self, name, dflt):
from django.conf import settings
return getattr(settings, self.prefix+name, dflt)
def __getattr__(self, name):
if name not in self.__DEFAULTS:
raise AttributeError(name)
return self._setting(name, self.__DEFAULTS[name])
# Ugly? Guido recommends this himself ...
# http://mail.python.org/pipermail/python-ideas/2012-May/014969.html
import sys
app_settings = AppSettings('A2_PLUGIN_TEMPLATE_')
app_settings.__name__ = __name__
sys.modules[__name__] = app_settings

View File

@ -1,3 +0,0 @@
from django import forms

View File

@ -1,4 +0,0 @@
from django.db import models
from django.utils.translation import ugettext_lazy as _
# put your models here

View File

@ -1,11 +0,0 @@
from django.conf.urls import url
from authentic2.decorators import setting_enabled, required
from . import app_settings
from .views import index
urlpatterns = required(
setting_enabled('ENABLE', settings=app_settings),
[url('^authentic2_plugin_template/$', index, name='authentic2-plugin-template-index')]
)

View File

@ -1,10 +0,0 @@
from django.shortcuts import render
from . import decorators
__ALL_ = [ 'sso' ]
@decorators.plugin_enabled
def index(request):
return render(request, 'authentic2_plugin_template/index.html')

View File

@ -156,14 +156,4 @@ setup(name="authentic2",
'compile_translations': compile_translations,
'sdist': sdist,
},
entry_points={
'authentic2.plugin': [
'authentic2-auth-ssl = authentic2.auth2_auth.auth2_ssl:Plugin',
'authentic2-auth-saml = authentic2_auth_saml:Plugin',
'authentic2-auth-oidc = authentic2_auth_oidc:Plugin',
'authentic2-idp-saml2 = authentic2.idp.saml:Plugin',
'authentic2-idp-cas = authentic2_idp_cas:Plugin',
'authentic2-idp-oidc = authentic2_idp_oidc:Plugin',
'authentic2-provisionning-ldap = authentic2_provisionning_ldap:Plugin',
],
})
)

View File

@ -1,11 +1,24 @@
import re
from django.apps import AppConfig
from django.apps import AppConfig, apps
from django.views import debug
from django.utils.module_loading import import_string
from . import plugins
class AppMixin(object):
def a2_hook_urls(self):
try:
return import_string('%s.urls.urlpatterns' % self.name)
except ImportError:
return None
class A2AppConfig(AppMixin, AppConfig):
pass
class Authentic2Config(AppConfig):
name = 'authentic2'
verbose_name = 'Authentic2'
@ -14,3 +27,16 @@ class Authentic2Config(AppConfig):
plugins.init()
debug.HIDDEN_SETTINGS = re.compile(
'API|TOKEN|KEY|SECRET|PASS|PROFANITIES_LIST|SIGNATURE|LDAP')
for app in apps.get_app_configs():
# apply only to authentic2 applications
if not app.name.startswith('authentic2_'):
continue
if app.__class__ is AppConfig:
# switch class if it's an application without a custom
# appconfig.
app.__class__ = A2AppConfig
else:
# add mixin to base classes if it's an application with a
# custom appconfig.
app.__class__.__bases__ = (AppMixin,) + app.__class__.__bases__

View File

@ -19,7 +19,7 @@ from django.core.files.storage import default_storage
from rest_framework import serializers
from .decorators import to_iter
from .decorators import to_iter, GlobalCache
from .plugins import collect_from_plugins
from . import app_settings
from .forms import widgets, fields
@ -231,11 +231,16 @@ DEFAULT_ATTRIBUTE_KINDS = [
]
@GlobalCache
def get_plugin_attribute_kinds():
return [kinds for kind in chain(*collect_from_plugins('attribute_kinds')) if kind]
def get_attribute_kinds():
attribute_kinds = {}
for attribute_kind in chain(DEFAULT_ATTRIBUTE_KINDS, app_settings.A2_ATTRIBUTE_KINDS):
attribute_kinds[attribute_kind['name']] = attribute_kind
for attribute_kind in chain(*collect_from_plugins('attribute_kinds')):
for attribute_kind in get_plugin_attribute_kinds():
attribute_kinds[attribute_kind['name']] = attribute_kind
return attribute_kinds

View File

@ -1,19 +1 @@
class Plugin(object):
def get_before_urls(self):
from . import app_settings
from django.conf.urls import include, url
from authentic2.decorators import setting_enabled, required
return required(
setting_enabled('ENABLE', settings=app_settings),
[
url(r'^accounts/sslauth/', include(__name__ + '.urls'))])
def get_apps(self):
return [__name__]
def get_authentication_backends(self):
return ['authentic2.auth2_auth.auth2_ssl.backends.SSLBackend']
def get_auth_frontends(self):
return ['authentic2.auth2_auth.auth2_ssl.frontends.SSLFrontend']
default_app_config = 'authentic2.auth2_auth.auth2_ssl.apps.AppConfig'

View File

@ -0,0 +1,29 @@
# authentic2.auth2_auth.auth2_ssl - Authentic2 Auth SSL plugin
# Copyright (C) 2018 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import django.apps
from authentic2.apps import AppMixin
class AppConfig(AppMixin, django.apps.AppConfig):
name = 'authentic2.auth2_auth.auth2_ssl'
def a2_hook_get_authentication_backends(self):
return ['authentic2.auth2_auth.auth2_ssl.backends.SSLBackend']
def a2_hook_get_auth_frontends(self):
return ['authentic2.auth2_auth.auth2_ssl.frontends.SSLFrontend']

View File

@ -1,6 +1,25 @@
from django.conf.urls import url
# authentic2.auth2_auth.auth2_ssl - Authentic2 Auth SSL plugin
# Copyright (C) 2018 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.conf.urls import include, url
from authentic2.decorators import setting_enabled, required
from .views import (handle_request, post_account_linking, delete_certificate,
error_ssl)
error_ssl)
from . import app_settings
urlpatterns = [
url(r'^$',
@ -16,3 +35,10 @@ urlpatterns = [
error_ssl,
name='error_ssl'),
]
urlpatterns = required(
setting_enabled('ENABLE', settings=app_settings),
[
url(r'^accounts/sslauth/', include(urlpatterns)),
]
)

View File

@ -3,7 +3,7 @@ import urlparse
from django.conf import settings
from . import plugins, app_settings
from . import plugins, app_settings, hooks
def make_origin(url):
@ -40,10 +40,4 @@ def check_origin(request, origin):
for whitelist_origin in app_settings.A2_CORS_WHITELIST:
if whitelist_origin == origin:
return True
for plugin in plugins.get_plugins():
if hasattr(plugin, 'check_origin'):
if plugin.check_origin(request, origin):
return True
return False
return any(hooks.call_hooks('check_origin', request, origin))

View File

@ -1,61 +1 @@
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
from django.core.checks import register, Warning, Tags
from django.apps import AppConfig
class Plugin(object):
def get_before_urls(self):
from . import app_settings
from django.conf.urls import url, include
from authentic2.decorators import (setting_enabled, required,
lasso_required)
return required(
(
setting_enabled('ENABLE', settings=app_settings),
lasso_required()
),
[url(r'^idp/saml2/', include(__name__ + '.urls'))])
def get_apps(self):
return ['authentic2.idp.saml']
def get_idp_backends(self):
return ['authentic2.idp.saml.backend.SamlBackend']
def check_origin(self, request, origin):
from authentic2.cors import make_origin
from authentic2.saml.models import LibertySession
for session in LibertySession.objects.filter(
django_session_key=request.session.session_key):
provider_origin = make_origin(session.provider_id)
if origin == provider_origin:
return True
class SAML2IdPConfig(AppConfig):
name = 'authentic2.idp.saml'
label = 'authentic2_idp_saml'
default_app_config = 'authentic2.idp.saml.SAML2IdPConfig'
def check_authentic2_config(app_configs, **kwargs):
from . import app_settings
errors = []
if not settings.DEBUG and app_settings.ENABLE and \
(app_settings.is_default('SIGNATURE_PUBLIC_KEY') or
app_settings.is_default('SIGNATURE_PRIVATE_KEY')):
errors.append(
Warning(
'You should not use default SAML keys in production',
hint='Generate new RSA keys and change the value of '
'A2_IDP_SAML2_SIGNATURE_PUBLIC_KEY and '
'A2_IDP_SAML2_SIGNATURE_PRIVATE_KEY in your setting file',
)
)
return errors
check_authentic2_config = register(Tags.security,
deploy=True)(check_authentic2_config)
default_app_config = 'authentic2.idp.saml.apps.AppConfig'

View File

@ -0,0 +1,64 @@
# authentic2.idp.saml - Authentic2 SAML IdP plugin
# Copyright (C) 2018 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import django.apps
from authentic2.apps import AppMixin
class AppConfig(AppMixin, django.apps.AppConfig):
name = 'authentic2.idp.saml'
label = 'idp_saml'
def a2_hook_get_idp_backends(self):
return ['authentic2.idp.saml.backend.SamlBackend']
def a2_hooks_check_origin(self, request, origin):
from authentic2.cors import make_origin
from authentic2.saml.models import LibertySession
for session in LibertySession.objects.filter(
django_session_key=request.session.session_key):
provider_origin = make_origin(session.provider_id)
if origin == provider_origin:
return True
def ready(self):
from django.core.checks import register, Tags
register(check_authentic2_config, Tags.security, deploy=True)
def check_authentic2_config(app_configs, **kwargs):
from . import app_settings
from django.conf import settings
from django.core.checks import Warning
errors = []
if not settings.DEBUG and app_settings.ENABLE and \
(app_settings.is_default('SIGNATURE_PUBLIC_KEY') or
app_settings.is_default('SIGNATURE_PRIVATE_KEY')):
errors.append(
Warning(
'You should not use default SAML keys in production',
hint='Generate new RSA keys and change the value of '
'A2_IDP_SAML2_SIGNATURE_PUBLIC_KEY and '
'A2_IDP_SAML2_SIGNATURE_PRIVATE_KEY in your setting file',
)
)
return errors

View File

@ -1,10 +1,27 @@
from django.conf.urls import url
# authentic2.idp.saml Authentic2 SAML IdP plugin
# Copyright (C) 2018 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from . import views
from authentic2.idp.saml.saml2_endpoints import (metadata, sso, continue_sso,
slo, slo_soap, idp_slo,
slo_return, finish_slo,
artifact, idp_sso)
from django.conf.urls import url, include
from authentic2.decorators import setting_enabled, required, lasso_required
from . import views, app_settings
from .saml2_endpoints import (metadata, sso, continue_sso, slo, slo_soap,
idp_slo, slo_return, finish_slo, artifact,
idp_sso)
urlpatterns = [
url(r'^metadata$', metadata, name='a2-idp-saml-metadata'),
@ -29,3 +46,7 @@ urlpatterns = [
views.delete_federation,
name='a2-idp-saml2-federation-delete'),
]
urlpatterns = required(
[setting_enabled('ENABLE', settings=app_settings),
lasso_required()],
[url(r'^idp/saml2/', include(urlpatterns))])

View File

@ -1,3 +1,19 @@
# authentic2_idp_oidc - Authentic2 OIDC IdP plugin
# Copyright (C) 2018 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Use setuptools entrypoints to find plugins
@ -6,42 +22,35 @@
import pkg_resources
import logging
from django.conf.urls import include, url
logger = logging.getLogger(__name__)
__ALL__ = ['get_plugins']
PLUGIN_CACHE = {}
class PluginError(Exception):
pass
DEFAULT_GROUP_NAME = 'authentic2.plugin'
def get_plugins(group_name=DEFAULT_GROUP_NAME, use_cache=True, *args, **kwargs):
def get_plugins(group_name=DEFAULT_GROUP_NAME, *args, **kwargs):
'''Traverse all entry points for group_name and instantiate them using args
and kwargs.
'''
global PLUGIN_CACHE
if group_name in PLUGIN_CACHE and use_cache:
return PLUGIN_CACHE[group_name]
plugins = []
for entrypoint in pkg_resources.iter_entry_points(group_name):
try:
plugin_callable = entrypoint.load()
except Exception, e:
except Exception as e:
logger = logging.getLogger(__name__)
logger.exception('unable to load entrypoint %s', entrypoint)
raise PluginError('unable to load entrypoint %s' % entrypoint, e)
plugins.append(plugin_callable(*args, **kwargs))
PLUGIN_CACHE[group_name] = plugins
return plugins
yield plugin_callable(*args, **kwargs)
def register_plugins_urls(urlpatterns,
group_name=DEFAULT_GROUP_NAME):
def register_urls(urlpatterns):
'''Call get_before_urls and get_after_urls on all plugins providing them
and add those urls to the given urlpatterns.
@ -49,7 +58,9 @@ def register_plugins_urls(urlpatterns,
and those returned by get_after_urls() are added to the tail of
urlpatterns.
'''
plugins = get_plugins(group_name)
from .hooks import call_hooks
plugins = get_plugins()
before_urls = []
after_urls = []
for plugin in plugins:
@ -59,14 +70,17 @@ def register_plugins_urls(urlpatterns,
if hasattr(plugin, 'get_after_urls'):
urls = plugin.get_after_urls()
after_urls.append(url('^', include(urls)))
# use new hook
for urls in call_hooks('urls'):
if urls:
before_urls.append(url('^', include(urls)))
return before_urls + urlpatterns + after_urls
def register_plugins_installed_apps(installed_apps, group_name=DEFAULT_GROUP_NAME):
'''Call get_apps() on all plugins of group_name and add the returned
applications path to the installed_apps sequence.
Applications already present are ignored.
applications path to the installed_apps sequence. Applications already
present are ignored.
'''
installed_apps = list(installed_apps)
for plugin in get_plugins(group_name):
@ -77,73 +91,44 @@ def register_plugins_installed_apps(installed_apps, group_name=DEFAULT_GROUP_NAM
installed_apps.append(app)
return installed_apps
def register_plugins_middleware(middleware_classes,
group_name=DEFAULT_GROUP_NAME):
middleware_classes = list(middleware_classes)
for plugin in get_plugins(group_name):
if hasattr(plugin, 'get_before_middleware'):
apps = plugin.get_before_middleware()
for app in reversed(apps):
if app not in middleware_classes:
middleware_classes.insert(0, app)
if hasattr(plugin, 'get_after_middleware'):
apps = plugin.get_after_middleware()
for app in apps:
if app not in middleware_classes:
middleware_classes.append(app)
return tuple(middleware_classes)
def register_plugins_authentication_backends(authentication_backends,
group_name=DEFAULT_GROUP_NAME):
authentication_backends = list(authentication_backends)
for plugin in get_plugins(group_name):
if hasattr(plugin, 'get_authentication_backends'):
cls = plugin.get_authentication_backends()
for cls in cls:
if cls not in authentication_backends:
authentication_backends.append(cls)
return tuple(authentication_backends)
def register_classes(what, initial=[]):
accum = list(initial)
def register_plugins_auth_frontends(auth_frontends=(),
group_name=DEFAULT_GROUP_NAME):
auth_frontends = list(auth_frontends)
for plugin in get_plugins(group_name):
if hasattr(plugin, 'get_auth_frontends'):
cls = plugin.get_auth_frontends()
for cls in cls:
if cls not in auth_frontends:
auth_frontends.append(cls)
return tuple(auth_frontends)
def register_plugins_idp_backends(idp_backends,
group_name=DEFAULT_GROUP_NAME):
idp_backends = list(idp_backends)
for plugin in get_plugins(group_name):
if hasattr(plugin, 'get_idp_backends'):
cls = plugin.get_idp_backends()
for cls in cls:
if cls not in idp_backends:
idp_backends.append(cls)
return tuple(idp_backends)
for klasses in collect_from_plugins(what):
for klass in klasses:
if klass not in accum:
accum.append(klass)
return accum
def collect_from_plugins(name, *args, **kwargs):
'''
Collect a property or the result of a function from plugins.
Collect a property or the result of a function from plugins and hooks.
'''
accumulator = []
from .hooks import call_hooks
for plugin in get_plugins():
if not hasattr(plugin, name):
continue
attribute = getattr(plugin, name)
if hasattr(attribute, '__call__'):
accumulator.append(attribute(*args, **kwargs))
yield attribute(*args, **kwargs)
else:
accumulator.append(attribute)
return accumulator
yield attribute
for value in call_hooks(name, *args, **kwargs):
if value:
yield value
def init():
from django.conf import settings
for plugin in get_plugins():
if hasattr(plugin, 'init'):
plugin.init()
settings.AUTHENTICATION_BACKENDS = register_classes('get_authentication_backends', settings.AUTHENTICATION_BACKENDS)
settings.AUTH_FRONTENDS = register_classes('get_auth_frontends', settings.AUTH_FRONTENDS)
settings.IDP_BACKENDS = register_classes('get_idp_backends', settings.IDP_BACKENDS)

View File

@ -85,11 +85,6 @@ MIDDLEWARE_CLASSES = (
'django.middleware.locale.LocaleMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
)
DATABASES['default']['ATOMIC_REQUESTS'] = True
MIDDLEWARE_CLASSES += (
'authentic2.middleware.DisplayMessageBeforeRedirectMiddleware',
'authentic2.idp.middleware.DebugMiddleware',
'authentic2.middleware.CollectIPMiddleware',
@ -97,7 +92,7 @@ MIDDLEWARE_CLASSES += (
'authentic2.middleware.OpenedSessionCookieMiddleware',
)
MIDDLEWARE_CLASSES = plugins.register_plugins_middleware(MIDDLEWARE_CLASSES)
DATABASES['default']['ATOMIC_REQUESTS'] = True
ROOT_URLCONF = 'authentic2.urls'
@ -126,6 +121,12 @@ INSTALLED_APPS = (
'authentic2.disco_service',
'authentic2.manager',
'authentic2_provisionning_ldap',
'authentic2_idp_cas',
'authentic2_idp_oidc',
'authentic2_auth_oidc',
'mellon',
'authentic2_auth_saml',
'authentic2.auth2_auth.auth2_ssl',
'authentic2',
'django_rbac',
'authentic2.a2_rbac',
@ -146,8 +147,6 @@ AUTHENTICATION_BACKENDS = (
'authentic2.backends.models_backend.DummyModelBackend',
'django_rbac.backends.DjangoRBACBackend',
)
AUTHENTICATION_BACKENDS = plugins.register_plugins_authentication_backends(
AUTHENTICATION_BACKENDS)
CSRF_FAILURE_VIEW = 'authentic2.views.csrf_failure_view'
@ -164,8 +163,7 @@ ACCOUNT_ACTIVATION_DAYS = 2
# Authentication settings
###########################
AUTH_USER_MODEL = 'custom_user.User'
AUTH_FRONTENDS = plugins.register_plugins_auth_frontends((
'authentic2.auth_frontends.LoginPasswordBackend',))
AUTH_FRONTENDS = ['authentic2.auth_frontends.LoginPasswordBackend']
###########################
# RBAC settings
@ -181,7 +179,7 @@ RBAC_ROLE_PARENTING_MODEL = 'a2_rbac.RoleParenting'
# List of IdP backends, mainly used to show available services in the homepage
# of user, and to handle SLO for each protocols
IDP_BACKENDS = plugins.register_plugins_idp_backends(())
IDP_BACKENDS = []
# Whether to autoload SAML 2.0 identity providers and services metadata
# Only https URLS are accepted.

View File

@ -56,4 +56,4 @@ if settings.DEBUG and 'debug_toolbar' in settings.INSTALLED_APPS:
url(r'^__debug__/', include(debug_toolbar.urls)),
] + urlpatterns
urlpatterns = plugins.register_plugins_urls(urlpatterns)
urlpatterns = plugins.register_urls(urlpatterns)

View File

@ -48,7 +48,7 @@ except ImportError:
from authentic2.saml.saml2utils import filter_attribute_private_key, \
filter_element_private_key
from . import plugins, app_settings, constants
from . import app_settings, constants
class CleanLogMessage(logging.Filter):
@ -119,6 +119,8 @@ class IterableFactory(object):
def accumulate_from_backends(request, method_name, **kwargs):
from . import plugins
list = []
for backend in get_backends():
method = getattr(backend, method_name, None)

View File

@ -511,13 +511,24 @@ class ProfileView(cbv.TemplateNamesMixin, TemplateView):
profile = login_required(ProfileView.as_view())
def logout_list(request):
'''Return logout links from idp backends'''
return utils.accumulate_from_backends(request, 'logout_list')
accum = utils.accumulate_from_backends(request, 'logout_list')
for result in hooks.call_hooks('logout_list', request):
if result:
accum.extend(result)
return accum
def redirect_logout_list(request):
'''Return redirect logout links from idp backends'''
return utils.accumulate_from_backends(request, 'redirect_logout_list')
accum = utils.accumulate_from_backends(request, 'redirect_logout_list')
for result in hooks.call_hooks('redirect_logout_list', request):
if result:
accum.extend(result)
return accum
def logout(request, next_url=None, default_next_url='auth_homepage',
redirect_field_name=REDIRECT_FIELD_NAME,

View File

@ -1,68 +1,17 @@
import logging
from django.utils.translation import ugettext_lazy as _
from django.core.urlresolvers import reverse
from authentic2.utils import make_url
class Plugin(object):
def get_before_urls(self):
from . import urls
return urls.urlpatterns
def get_apps(self):
return [__name__]
def get_authentication_backends(self):
return ['authentic2_auth_oidc.backends.OIDCBackend']
def get_auth_frontends(self):
return ['authentic2_auth_oidc.auth_frontends.OIDCFrontend']
def redirect_logout_list(self, request, next=None):
from .models import OIDCProvider
tokens = request.session.get('auth_oidc', {}).get('tokens', [])
urls = []
if tokens:
for token in tokens:
provider = OIDCProvider.objects.get(pk=token['provider_pk'])
# ignore providers wihtout SLO
if not provider.end_session_endpoint:
continue
params = {}
if 'id_token' in token['token_response']:
params['id_token_hint'] = token['token_response']['id_token']
if 'access_token' in token['token_response'] and provider.token_revocation_endpoint:
self.revoke_token(provider, token['token_response']['access_token'])
params['post_logout_redirect_uri'] = request.build_absolute_uri(reverse('auth_logout'))
urls.append(make_url(provider.end_session_endpoint, params=params))
return urls
def revoke_token(self, provider, access_token):
import requests
logger = logging.getLogger(__name__)
url = provider.token_revocation_endpoint
try:
response = requests.post(url, auth=(provider.client_id, provider.client_secret),
data={'token': access_token, 'token_type': 'access_token'},
timeout=10)
except requests.RequestException as e:
logger.warning(u'failed to revoke access token from OIDC provider %s: %s',
provider.issuer, e)
return
try:
response.raise_for_status()
except requests.RequestException as e:
try:
content = response.json()
except ValueError:
content = None
logger.warning(u'failed to revoke access token from OIDC provider %s: %s, %s',
provider.issuer, e, content)
return
logger.info(u'revoked token from OIDC provider %s', provider.issuer)
# authentic2_idp_oidc - Authentic2 OIDC IdP plugin
# Copyright (C) 2018 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
default_app_config = 'authentic2_auth_oidc.apps.AppConfig'

View File

@ -0,0 +1,77 @@
# authentic2_idp_oidc - Authentic2 OIDC IdP plugin
# Copyright (C) 2018 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import django.apps
class AppConfig(django.apps.AppConfig):
name = 'authentic2_auth_oidc'
def a2_hook_get_authentication_backends(self):
return ['authentic2_auth_oidc.backends.OIDCBackend']
def a2_hook_get_auth_frontends(self):
return ['authentic2_auth_oidc.auth_frontends.OIDCFrontend']
def a2_hook_redirect_logout_list(self, request, next=None):
from .models import OIDCProvider
from authentic2.utils import make_url
from django.core.urlresolvers import reverse
tokens = request.session.get('auth_oidc', {}).get('tokens', [])
urls = []
if tokens:
for token in tokens:
provider = OIDCProvider.objects.get(pk=token['provider_pk'])
# ignore providers wihtout SLO
if not provider.end_session_endpoint:
continue
params = {}
if 'id_token' in token['token_response']:
params['id_token_hint'] = token['token_response']['id_token']
if 'access_token' in token['token_response'] and provider.token_revocation_endpoint:
self.revoke_token(provider, token['token_response']['access_token'])
params['post_logout_redirect_uri'] = request.build_absolute_uri(reverse('auth_logout'))
urls.append(make_url(provider.end_session_endpoint, params=params))
return urls
def revoke_token(self, provider, access_token):
import requests
import logging
logger = logging.getLogger(__name__)
url = provider.token_revocation_endpoint
try:
response = requests.post(url, auth=(provider.client_id, provider.client_secret),
data={'token': access_token, 'token_type': 'access_token'},
timeout=10)
except requests.RequestException as e:
logger.warning(u'failed to revoke access token from OIDC provider %s: %s',
provider.issuer, e)
return
try:
response.raise_for_status()
except requests.RequestException as e:
try:
content = response.json()
except ValueError:
content = None
logger.warning(u'failed to revoke access token from OIDC provider %s: %s, %s',
provider.issuer, e, content)
return
logger.info(u'revoked token from OIDC provider %s', provider.issuer)

View File

@ -1,21 +1 @@
class Plugin(object):
def get_before_urls(self):
from . import urls
return urls.urlpatterns
def get_apps(self):
return ['mellon', __name__]
def get_authentication_backends(self):
return ['authentic2_auth_saml.backends.SAMLBackend']
def get_auth_frontends(self):
return ['authentic2_auth_saml.auth_frontends.SAMLFrontend']
def redirect_logout_list(self, request, next_url=None):
from mellon.views import logout
if 'mellon_session' in request.session:
response = logout(request)
if 'Location' in response:
return [response['Location']]
return []
default_app_config = 'authentic2_auth_saml.apps.AppConfig'

View File

@ -0,0 +1,36 @@
# authentic2_idp_oidc - Authentic2 OIDC IdP plugin
# Copyright (C) 2018 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import django.apps
class AppConfig(django.apps.AppConfig):
name = 'authentic2_auth_saml'
def a2_hook_get_authentication_backends(self):
return ['authentic2_auth_saml.backends.SAMLBackend']
def a2_hook_get_auth_frontends(self):
return ['authentic2_auth_saml.auth_frontends.SAMLFrontend']
def a2_hook_redirect_logout_list(self, request, next_url=None):
from mellon.views import logout
if 'mellon_session' in request.session:
response = logout(request)
if 'Location' in response:
return [response['Location']]
return []

View File

@ -1,33 +1 @@
from django.template.loader import render_to_string
from django.utils.translation import ugettext_lazy as _
from .constants import SESSION_CAS_LOGOUTS
class Plugin(object):
def get_before_urls(self):
from . import app_settings
from django.conf.urls import include, url
from authentic2.decorators import setting_enabled, required
return required(
(
setting_enabled('ENABLE', settings=app_settings),
),
[url(r'^idp/cas/', include(__name__ + '.urls'))])
def get_apps(self):
return [__name__]
def logout_list(self, request):
fragments = []
cas_logouts = request.session.get(SESSION_CAS_LOGOUTS, [])
for name, url, use_iframe, use_iframe_timeout in cas_logouts:
ctx = {
'needs_iframe': use_iframe,
'name': name,
'url': url,
'iframe_timeout': use_iframe_timeout,
}
content = render_to_string('authentic2_idp_cas/logout_fragment.html', ctx)
fragments.append(content)
return fragments
default_app_config = 'authentic2_idp_cas.apps.AppConfig'

View File

@ -0,0 +1,39 @@
# authentic2_idp_oidc - Authentic2 OIDC IdP plugin
# Copyright (C) 2018 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import django.apps
class AppConfig(django.apps.AppConfig):
name = 'authentic2_idp_cas'
def a2_hook_logout_list(self, request):
from .constants import SESSION_CAS_LOGOUTS
from django.template.loader import render_to_string
fragments = []
cas_logouts = request.session.get(SESSION_CAS_LOGOUTS, [])
for name, url, use_iframe, use_iframe_timeout in cas_logouts:
ctx = {
'needs_iframe': use_iframe,
'name': name,
'url': url,
'iframe_timeout': use_iframe_timeout,
}
content = render_to_string('authentic2_idp_cas/logout_fragment.html', ctx)
fragments.append(content)
return fragments

View File

@ -1,6 +1,8 @@
from django.conf.urls import url
from django.conf.urls import include, url
from . import views
from authentic2.decorators import setting_enabled, required
from . import views, app_settings
urlpatterns = [
url('^login/?$', views.login, name='a2-idp-cas-login'),
@ -13,3 +15,9 @@ urlpatterns = [
url('^proxyValidate/?$', views.proxy_validate,
name='a2-idp-cas-proxy-validate'),
]
urlpatterns = required(
[setting_enabled('ENABLE', settings=app_settings)],
[
url(r'^idp/cas/', include(urlpatterns)),
]
)

View File

@ -1,34 +1 @@
from django.template.loader import render_to_string
from django.utils.translation import ugettext_lazy as _
default_app_config = 'authentic2_idp_oidc.apps.AppConfig'
class Plugin(object):
def get_before_urls(self):
from . import urls
return urls.urlpatterns
def get_apps(self):
return [__name__]
def logout_list(self, request):
from .utils import get_oidc_sessions
from . import app_settings
fragments = []
oidc_sessions = get_oidc_sessions(request)
for key, value in oidc_sessions.iteritems():
if 'frontchannel_logout_uri' not in value:
continue
ctx = {
'url': value['frontchannel_logout_uri'],
'name': value['name'],
'iframe_timeout': value.get('frontchannel_timeout') or app_settings.DEFAULT_FRONTCHANNEL_TIMEOUT,
}
fragments.append(
render_to_string(
'authentic2_idp_oidc/logout_fragment.html',
ctx))
return fragments

View File

@ -1,5 +1,5 @@
# authentic2_idp_oidc - Authentic2 OIDC IdP plugin
# Copyright (C) 2017 Entr'ouvert
# Copyright (C) 2018 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
@ -17,94 +17,114 @@
import django.apps
from django.utils.encoding import smart_bytes
from rest_framework.exceptions import APIException
class AppConfig(django.apps.AppConfig):
name = 'authentic2_idp_oidc'
name = 'authentic2_idp_oidc'
# implement translation of encrypted pairwise identifiers when and OIDC Client is using the
# A2 API
def a2_hook_api_modify_serializer(self, view, serializer):
from . import utils
from rest_framework import serializers
# implement translation of encrypted pairwise identifiers when and OIDC Client is using the
# A2 API
def a2_hook_api_modify_serializer(self, view, serializer):
from . import utils
from rest_framework import serializers
if hasattr(view.request.user, 'oidc_client'):
client = view.request.user.oidc_client
if client.identifier_policy == client.POLICY_PAIRWISE_REVERSIBLE:
if hasattr(view.request.user, 'oidc_client'):
client = view.request.user.oidc_client
if client.identifier_policy == client.POLICY_PAIRWISE_REVERSIBLE:
def get_oidc_uuuid(user):
return utils.make_pairwise_reversible_sub(client, user)
serializer.get_oidc_uuid = get_oidc_uuuid
serializer.fields['uuid'] = serializers.SerializerMethodField(
method_name='get_oidc_uuid')
def get_oidc_uuuid(user):
return utils.make_pairwise_reversible_sub(client, user)
serializer.get_oidc_uuid = get_oidc_uuuid
serializer.fields['uuid'] = serializers.SerializerMethodField(
method_name='get_oidc_uuid')
def a2_hook_api_modify_view_before_get_object(self, view):
'''Decrypt sub used as pk argument in URL.'''
import uuid
from . import utils
def a2_hook_api_modify_view_before_get_object(self, view):
'''Decrypt sub used as pk argument in URL.'''
import uuid
from . import utils
request = view.request
if not hasattr(request.user, 'oidc_client'):
return
client = request.user.oidc_client
if client.identifier_policy != client.POLICY_PAIRWISE_REVERSIBLE:
return
lookup_url_kwarg = view.lookup_url_kwarg or view.lookup_field
if lookup_url_kwarg not in view.kwargs:
return
request = view.request
if not hasattr(request.user, 'oidc_client'):
return
client = request.user.oidc_client
if client.identifier_policy != client.POLICY_PAIRWISE_REVERSIBLE:
return
lookup_url_kwarg = view.lookup_url_kwarg or view.lookup_field
if lookup_url_kwarg not in view.kwargs:
return
sub = smart_bytes(view.kwargs[lookup_url_kwarg])
decrypted = utils.reverse_pairwise_sub(client, sub)
sub = smart_bytes(view.kwargs[lookup_url_kwarg])
decrypted = utils.reverse_pairwise_sub(client, sub)
if decrypted:
view.kwargs[lookup_url_kwarg] = uuid.UUID(bytes=decrypted).hex
def a2_hook_api_modify_serializer_after_validation(self, view, serializer):
import uuid
from . import utils
if view.__class__.__name__ != 'UsersAPI':
return
if serializer.__class__.__name__ != 'SynchronizationSerializer':
return
request = view.request
if not hasattr(request.user, 'oidc_client'):
return
client = request.user.oidc_client
if client.identifier_policy != client.POLICY_PAIRWISE_REVERSIBLE:
return
new_known_uuids = []
uuid_map = request.uuid_map = {}
request.unknown_uuids = []
for u in serializer.validated_data['known_uuids']:
decrypted = utils.reverse_pairwise_sub(client, smart_bytes(u))
if decrypted:
view.kwargs[lookup_url_kwarg] = uuid.UUID(bytes=decrypted).hex
new_known_uuid = uuid.UUID(bytes=decrypted).hex
new_known_uuids.append(new_known_uuid)
uuid_map[new_known_uuid] = u
else:
request.unknown_uuids.append(u)
# undecipherable sub are just not checked at all
serializer.validated_data['known_uuids'] = new_known_uuids
def a2_hook_api_modify_serializer_after_validation(self, view, serializer):
import uuid
from . import utils
def a2_hook_api_modify_response(self, view, method_name, data):
'''Reverse mapping applied in a2_hook_api_modify_serializer_after_validation using the
uuid_map saved on the request.
'''
request = view.request
if not hasattr(request.user, 'oidc_client'):
return
if view.__class__.__name__ != 'UsersAPI':
return
if method_name != 'synchronization':
return
if not hasattr(request, 'uuid_map'):
return
uuid_map = request.uuid_map
if view.__class__.__name__ != 'UsersAPI':
return
if serializer.__class__.__name__ != 'SynchronizationSerializer':
return
request = view.request
if not hasattr(request.user, 'oidc_client'):
return
client = request.user.oidc_client
if client.identifier_policy != client.POLICY_PAIRWISE_REVERSIBLE:
return
new_known_uuids = []
uuid_map = request.uuid_map = {}
request.unknown_uuids = []
for u in serializer.validated_data['known_uuids']:
decrypted = utils.reverse_pairwise_sub(client, smart_bytes(u))
if decrypted:
new_known_uuid = uuid.UUID(bytes=decrypted).hex
new_known_uuids.append(new_known_uuid)
uuid_map[new_known_uuid] = u
else:
request.unknown_uuids.append(u)
# undecipherable sub are just not checked at all
serializer.validated_data['known_uuids'] = new_known_uuids
unknown_uuids = data['unknown_uuids']
new_unknown_uuids = []
for u in unknown_uuids:
new_unknown_uuids.append(uuid_map[u])
new_unknown_uuids.extend(request.unknown_uuids)
data['unknown_uuids'] = new_unknown_uuids
def a2_hook_api_modify_response(self, view, method_name, data):
'''Reverse mapping applied in a2_hook_api_modify_serializer_after_validation using the
uuid_map saved on the request.
'''
request = view.request
if not hasattr(request.user, 'oidc_client'):
return
if view.__class__.__name__ != 'UsersAPI':
return
if method_name != 'synchronization':
return
if not hasattr(request, 'uuid_map'):
return
uuid_map = request.uuid_map
def a2_hook_logout_list(self, request):
from .utils import get_oidc_sessions
from django.template.loader import render_to_string
from . import app_settings
unknown_uuids = data['unknown_uuids']
new_unknown_uuids = []
for u in unknown_uuids:
new_unknown_uuids.append(uuid_map[u])
new_unknown_uuids.extend(request.unknown_uuids)
data['unknown_uuids'] = new_unknown_uuids
fragments = []
oidc_sessions = get_oidc_sessions(request)
for key, value in oidc_sessions.iteritems():
if 'frontchannel_logout_uri' not in value:
continue
ctx = {
'url': value['frontchannel_logout_uri'],
'name': value['name'],
'iframe_timeout': value.get('frontchannel_timeout') or app_settings.DEFAULT_FRONTCHANNEL_TIMEOUT,
}
fragments.append(
render_to_string(
'authentic2_idp_oidc/logout_fragment.html',
ctx))
return fragments

View File

@ -1,3 +0,0 @@
class Plugin(object):
def get_apps(self):
return [__name__]