summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSerghei Mihai <smihai@entrouvert.com>2020-08-13 08:53:00 (GMT)
committerSerghei Mihai <smihai@entrouvert.com>2020-11-03 12:35:36 (GMT)
commitc5b8739ef093e410eeb535764b54d607eded73b6 (patch)
treedeabf040ae260c4e00c8c3d72b4c7933da1e5ef5
parente3be677fbeaa5a1dfe2bdd8085b5e128359cd2cd (diff)
downloadauthentic-wip/39406-manage-authenticators.zip
authentic-wip/39406-manage-authenticators.tar.gz
authentic-wip/39406-manage-authenticators.tar.bz2
manager: add authenticators management (#39406)wip/39406-manage-authenticators
-rw-r--r--src/authentic2/app_settings.py3
-rw-r--r--src/authentic2/authenticators.py133
-rw-r--r--src/authentic2/manager/authenticators_views.py236
-rw-r--r--src/authentic2/manager/forms.py37
-rw-r--r--src/authentic2/manager/static/authentic2/manager/css/style.css24
-rw-r--r--src/authentic2/manager/static/authentic2/manager/js/manager.js12
-rw-r--r--src/authentic2/manager/templates/authentic2/manager/authenticator_add_form.html12
-rw-r--r--src/authentic2/manager/templates/authentic2/manager/authenticator_management_form.html12
-rw-r--r--src/authentic2/manager/templates/authentic2/manager/authenticators_add_list.html23
-rw-r--r--src/authentic2/manager/templates/authentic2/manager/authenticators_home.html23
-rw-r--r--src/authentic2/manager/templates/authentic2/manager/authenticators_order.html12
-rw-r--r--src/authentic2/manager/templates/authentic2/manager/homepage.html1
-rw-r--r--src/authentic2/manager/urls.py14
-rw-r--r--src/authentic2/views.py9
-rw-r--r--src/authentic2_auth_fc/app_settings.py4
-rw-r--r--src/authentic2_auth_fc/authenticators.py43
-rw-r--r--src/authentic2_auth_fc/manager/__init__.py0
-rw-r--r--src/authentic2_auth_fc/manager/forms.py67
-rw-r--r--src/authentic2_auth_oidc/app_settings.py4
-rw-r--r--src/authentic2_auth_oidc/authenticators.py75
-rw-r--r--src/authentic2_auth_oidc/manager/__init__.py0
-rw-r--r--src/authentic2_auth_oidc/manager/forms.py54
-rw-r--r--src/authentic2_auth_oidc/migrations/0008_oidcprovider_priority.py20
-rw-r--r--src/authentic2_auth_oidc/models.py5
-rw-r--r--src/authentic2_auth_saml/adapters.py6
-rw-r--r--src/authentic2_auth_saml/app_settings.py16
-rw-r--r--src/authentic2_auth_saml/authenticators.py87
-rw-r--r--src/authentic2_auth_saml/manager/__init__.py0
-rw-r--r--src/authentic2_auth_saml/manager/forms.py95
-rw-r--r--tests/test_login.py13
-rw-r--r--tests/test_manager.py93
31 files changed, 1114 insertions, 19 deletions
diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py
index a896b05..07f47e7 100644
--- a/src/authentic2/app_settings.py
+++ b/src/authentic2/app_settings.py
@@ -200,6 +200,9 @@ default_settings = dict(
A2_LOGIN_FORM_OU_SELECTOR_LABEL=Setting(
default=None,
definition='Label of OU field on login page'),
+ A2_LOGIN_FORM_PRIORITY=Setting(
+ default=0,
+ definition='Login form priority on login page'),
A2_REGISTRATION_USERNAME_IS_UNIQUE=Setting(
default=True,
definition='Check username uniqueness on registration'),
diff --git a/src/authentic2/authenticators.py b/src/authentic2/authenticators.py
index 0f9ba78..a2029e8 100644
--- a/src/authentic2/authenticators.py
+++ b/src/authentic2/authenticators.py
@@ -21,7 +21,9 @@ from django.shortcuts import render
from django.utils.translation import ugettext as _, ugettext_lazy
from authentic2.a2_rbac.models import OrganizationalUnit as OU, Role
+from authentic2.a2_settings import set_setting
from authentic2.custom_user.models import User
+from authentic2.manager.forms import LoginPasswordAuthenticatorManagerEditForm
from . import views, app_settings, utils
from .utils.views import csrf_token_check
from .utils.service import get_service_from_request
@@ -31,6 +33,52 @@ from .utils.evaluate import evaluate_condition
logger = logging.getLogger(__name__)
+class ManagerFormNotFound(Exception):
+ pass
+
+
+class BaseAuthenticatorManagedInstance:
+ def __init__(self, authenticator, instance=None):
+ self.instance=instance
+ self.authenticator = authenticator
+
+ def is_unique(self):
+ return self.instance is None
+
+ @property
+ def priority(self):
+ return self.authenticator.priority
+
+ @property
+ def name(self):
+ return self.authenticator.name()
+
+ @property
+ def slug(self):
+ return self.authenticator.id
+
+ @property
+ def title(self):
+ return self.name
+
+ def enabled(self):
+ return self.authenticator.enabled()
+
+ def get_manager_form_initial_values(self):
+ return self.authenticator.get_manager_form_initial_values()
+
+
+class LoginPasswordAuthenticatorManagedInstance(BaseAuthenticatorManagedInstance):
+
+ def get_manager_form_initial_values(self):
+ return {
+ 'remember_me': app_settings.A2_USER_REMEMBER_ME,
+ 'retry_duration': app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_DURATION,
+ 'login_form_ou_selector': app_settings.A2_LOGIN_FORM_OU_SELECTOR
+ }
+
+
+
class BaseAuthenticator(object):
def __init__(self, show_condition=None, **kwargs):
@@ -54,15 +102,74 @@ class BaseAuthenticator(object):
logger.error(e)
return False
+ def get_manager_add_form(self):
+ raise ManagerFormNotFound
+
+ def get_manager_edit_form(self):
+ raise ManagerFormNotFound
+
+ def is_configured(self):
+ return False
+
+ def get_instance_wrapper_class(self):
+ return BaseAuthenticatorManagedInstance
+
+ def get_instances(self):
+ instance_class = self.get_instance_wrapper_class()
+ if hasattr(self, 'instances'):
+ for slug, priority, instance in self.instances(shown=None):
+ yield instance_class(self, instance)
+ else:
+ yield instance_class(self)
+
+ def set_priority(self, priority):
+ raise NotImplementedError
+
+ def set_instance_priority(self, priority):
+ raise NotImplementedError
+
+ def get_instances_forms(self):
+ try:
+ form = self.get_manager_edit_form()
+ except ManagerFormNotFound:
+ logger.debug('Manager form for %s not found' % self.name())
+ return
+
+ if not self.is_configured():
+ return
+
+ for instance in self.get_instances():
+ form_id = '%s-form' % (instance.slug)
+ yield form_id, form, instance
+
+ @property
+ def priority(self):
+ return 999
+
class LoginPasswordAuthenticator(BaseAuthenticator):
id = 'password'
submit_name = 'login-password-submit'
- priority = 0
+
+ @property
+ def priority(self):
+ return app_settings.A2_LOGIN_FORM_PRIORITY
+
+ def set_priority(self, priority):
+ set_setting('A2_LOGIN_FORM_PRIORITY', priority)
def enabled(self):
return app_settings.A2_AUTH_PASSWORD_ENABLE
+ def is_configured(self):
+ return True
+
+ def enable(self):
+ set_setting('A2_AUTH_PASSWORD_ENABLE', True)
+
+ def disable(self):
+ set_setting('A2_AUTH_PASSWORD_ENABLE', False)
+
def name(self):
return ugettext_lazy('Password')
@@ -71,7 +178,8 @@ class LoginPasswordAuthenticator(BaseAuthenticator):
if not roles:
return []
service_ou_ids = []
- qs = User.objects.filter(roles__in=roles).values_list('ou').annotate(count=Count('ou')).order_by('-count')
+ qs = User.objects.filter(roles__in=roles).values_list(
+ 'ou').annotate(count=Count('ou')).order_by('-count')
for ou_id, count in qs:
if not ou_id:
continue
@@ -81,10 +189,12 @@ class LoginPasswordAuthenticator(BaseAuthenticator):
return OU.objects.filter(pk__in=service_ou_ids)
def get_preferred_ous(self, request, service):
- preferred_ous_cookie = utils.get_remember_cookie(request, 'preferred-ous')
+ preferred_ous_cookie = utils.get_remember_cookie(
+ request, 'preferred-ous')
preferred_ous = []
if preferred_ous_cookie:
- preferred_ous.extend(OU.objects.filter(pk__in=preferred_ous_cookie))
+ preferred_ous.extend(OU.objects.filter(
+ pk__in=preferred_ous_cookie))
# for the special case of services open to only one OU, pre-select it
if service:
for ou in self.get_service_ous(service):
@@ -127,10 +237,13 @@ class LoginPasswordAuthenticator(BaseAuthenticator):
how = 'password'
if form.cleaned_data.get('remember_me'):
request.session['remember_me'] = True
- request.session.set_expiry(app_settings.A2_USER_REMEMBER_ME)
- response = utils.login(request, form.get_user(), how, service=service)
+ request.session.set_expiry(
+ app_settings.A2_USER_REMEMBER_ME)
+ response = utils.login(
+ request, form.get_user(), how, service=service)
if 'ou' in form.fields:
- utils.prepend_remember_cookie(request, response, 'preferred-ous', form.cleaned_data['ou'].pk)
+ utils.prepend_remember_cookie(
+ request, response, 'preferred-ous', form.cleaned_data['ou'].pk)
return response
context['form'] = form
@@ -142,3 +255,9 @@ class LoginPasswordAuthenticator(BaseAuthenticator):
def registration(self, request, *args, **kwargs):
context = kwargs.get('context', {})
return render(request, 'authentic2/login_password_registration_form.html', context)
+
+ def get_manager_edit_form(self):
+ return LoginPasswordAuthenticatorManagerEditForm
+
+ def get_instance_wrapper_class(self):
+ return LoginPasswordAuthenticatorManagedInstance
diff --git a/src/authentic2/manager/authenticators_views.py b/src/authentic2/manager/authenticators_views.py
new file mode 100644
index 0000000..78eedb5
--- /dev/null
+++ b/src/authentic2/manager/authenticators_views.py
@@ -0,0 +1,236 @@
+# authentic2 - versatile identity manager
+# Copyright (C) 2010-2020 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.contrib import messages
+from django.http import Http404
+from django.shortcuts import render
+from django.utils.translation import ugettext as _
+from django.urls import reverse_lazy
+from django.views.generic.edit import FormView
+
+from authentic2 import app_settings
+from authentic2 import utils
+from authentic2.authenticators import ManagerFormNotFound
+
+from django.views.generic import TemplateView, RedirectView
+
+from . import forms
+from . import views
+
+
+class AuthenticatorsMixin:
+
+ @property
+ def authenticators(self):
+ authenticators = []
+ for authenticator_path in app_settings.AUTH_FRONTENDS:
+ authenticators.append(utils.load_backend(
+ authenticator_path, app_settings.AUTH_FRONTENDS_KWARGS))
+ return authenticators
+
+ def get_authenticator(self, auth_id):
+ for authenticator in self.authenticators:
+ if authenticator.id == auth_id:
+ return authenticator
+ raise Http404(_('Authenticator not found'))
+
+
+class AuthenticatorsHomeView(views.MediaMixin, views.TitleMixin, AuthenticatorsMixin, TemplateView):
+ template_name = 'authentic2/manager/authenticators_home.html'
+ title = _('Authentication settings')
+
+ def get_form_kwargs(self, **kwargs):
+ form_id = kwargs.pop('form_id')
+ instance = kwargs.pop('instance')
+ kwargs['prefix'] = form_id
+ kwargs['initial'] = instance.get_manager_form_initial_values()
+ if not instance.is_unique():
+ kwargs['instance'] = instance
+ if form_id in self.request.POST:
+ kwargs.update({'data': self.request.POST})
+ return kwargs
+
+ def get_forms(self):
+ forms = []
+ for auth in self.authenticators:
+ for form_id, form, instance in auth.get_instances_forms():
+ forms.append((form_id, form, instance))
+ # sort form by instances priorities
+ forms.sort(key=lambda f: f[2].priority)
+ for form_id, form, instance in forms:
+ form_kwargs = self.get_form_kwargs(
+ instance=instance, form_id=form_id)
+ yield form_id, instance, form(**form_kwargs)
+
+ def render_form(self, **kwargs):
+ template_name = 'authentic2/manager/authenticator_management_form.html'
+ return render(self.request, template_name, kwargs)
+
+ def get_context_data(self, *args, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context['authenticators_forms'] = []
+ for form_id, instance, form in self.get_forms():
+ kwargs = {'instance_slug': instance.slug,
+ 'action_name': 'enable'
+ }
+ if instance.enabled():
+ kwargs['action_name'] = 'disable'
+
+ action_url = reverse_lazy('a2-manager-authenticator-toggle-instance', kwargs=kwargs)
+ rendered_form = self.render_form(form_id=form_id, form=form,
+ instance=instance,
+ action_url=action_url)
+ context['authenticators_forms'].append(rendered_form)
+ return context
+
+ def post(self, request, *args, **kwargs):
+ for form_id, instance, form in self.get_forms():
+ if form_id in request.POST and form.is_valid():
+ form.save()
+ messages.success(self.request, _(
+ '%s authentication settings updated') % instance.name)
+ return utils.redirect(request, 'a2-manager-authenticators-homepage')
+ return super().get(request, *args, **kwargs)
+
+
+home = AuthenticatorsHomeView.as_view()
+
+
+class AuthenticatorAddView(views.MediaMixin, views.TitleMixin, AuthenticatorsMixin, FormView):
+ template_name = 'authentic2/manager/authenticator_add_form.html'
+ success_url = reverse_lazy('a2-manager-authenticators-homepage')
+
+ def get_form_class(self):
+ authenticator = self.get_authenticator(self.kwargs['auth_id'])
+ try:
+ return authenticator.get_manager_add_form()
+ except ManagerFormNotFound:
+ return None
+
+ def get_context_data(self, **kwargs):
+ ctx = super().get_context_data()
+ ctx['authenticator'] = self.get_authenticator(self.kwargs['auth_id'])
+ return ctx
+
+ def form_valid(self, form):
+ form.save()
+ authenticator = self.get_authenticator(self.kwargs['auth_id'])
+ messages.success(self.request, _(
+ 'Authentication via %s added') % authenticator.name())
+ return super().form_valid(form)
+
+
+authenticator_add = AuthenticatorAddView.as_view()
+
+
+class AuthenticatorsAddListView(views.MediaMixin, views.TitleMixin, TemplateView, AuthenticatorsMixin):
+ template_name = 'authentic2/manager/authenticators_add_list.html'
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ auths = []
+ for authenticator in self.authenticators:
+ try:
+ authenticator.get_manager_add_form()
+ except ManagerFormNotFound:
+ continue
+ if authenticator.is_configured() and not hasattr(authenticator, 'instances'):
+ continue
+ auths.append(authenticator)
+ context['authenticators'] = auths
+ return context
+
+
+authenticators_add_list = AuthenticatorsAddListView.as_view()
+
+
+class ToggleAuthenticatorInstanceView(RedirectView, AuthenticatorsMixin):
+ http_method_names = ['get']
+ url = reverse_lazy('a2-manager-authenticators-homepage')
+
+ def get(self, request, *args, **kwargs):
+ parts = kwargs['instance_slug'].split('-', 1)
+ self.authenticator = self.get_authenticator(parts[0])
+
+ action_name = kwargs['action_name']
+
+ if len(parts) > 1:
+ getattr(self, action_name)(''.join(parts[1:]))
+ else:
+ getattr(self, action_name)()
+ return super().get(request, *args, **kwargs)
+
+ def enable(self, instance_slug=None):
+ if instance_slug:
+ self.authenticator.instance_enable(instance_slug)
+ else:
+ self.authenticator.enable()
+
+ def disable(self, instance_slug=None):
+ if instance_slug:
+ self.authenticator.instance_disable(instance_slug)
+ else:
+ self.authenticator.disable()
+
+
+toggle_authenticator_instance = ToggleAuthenticatorInstanceView.as_view()
+
+
+class AuthenticatorsOrderConfigView(AuthenticatorsMixin, TemplateView, FormView):
+ template_name = 'authentic2/manager/authenticators_order.html'
+ success_url = reverse_lazy('a2-manager-authenticators-homepage')
+ form_class = forms.AuthenticatorsOrderForm
+
+ def get_auth_instances(self):
+ instances = []
+ for auth in self.authenticators:
+ if not auth.is_configured():
+ continue
+ try:
+ form = auth.get_manager_edit_form()
+ except ManagerFormNotFound:
+ continue
+ for instance in auth.get_instances():
+ instances.append(instance)
+ # sort instances by priority
+ instances.sort(key=lambda i: i.priority)
+ return instances
+
+ def get_initial(self):
+ return {
+ 'order': ','.join([i.slug for i in self.get_auth_instances()])
+ }
+
+ def form_valid(self, form):
+ if form.cleaned_data['order']:
+ for idx, block in enumerate(form.cleaned_data['order'].split(',')):
+ parts = block.split('-', 1)
+ auth = self.get_authenticator(parts[0])
+ if len(parts) > 1:
+ auth.set_instance_priority(parts[1], idx)
+ else:
+ auth.set_priority(idx)
+ return super().form_valid(form)
+
+ def get_context_data(self, *args, **kwargs):
+ ctx = super().get_context_data(*args, **kwargs)
+ ctx['instances'] = []
+ for instance in self.get_auth_instances():
+ ctx['instances'].append(instance)
+ return ctx
+
+
+authenticators_order_config = AuthenticatorsOrderConfigView.as_view()
diff --git a/src/authentic2/manager/forms.py b/src/authentic2/manager/forms.py
index b871773..8c5657a 100644
--- a/src/authentic2/manager/forms.py
+++ b/src/authentic2/manager/forms.py
@@ -40,6 +40,7 @@ from authentic2.forms.profile import BaseUserForm
from authentic2.models import PasswordReset
from authentic2.utils import import_module_or_class
from authentic2.a2_rbac.utils import get_default_ou
+from authentic2.a2_settings import set_setting
from authentic2.utils import send_password_reset_mail, send_email_change_email
from authentic2 import app_settings as a2_app_settings
@@ -768,3 +769,39 @@ class UserEditImportForm(UserImportForm):
with self.user_import.meta_update as meta:
meta['ou'] = self.cleaned_data['ou']
meta['encoding'] = self.cleaned_data['encoding']
+
+
+class LoginPasswordAuthenticatorManagerEditForm(forms.Form):
+ css_class = 'pk-mark-optional-fields'
+
+ remember_me = forms.ChoiceField(
+ label=_('Include "Remember me" option'),
+ choices=[(2592000, _('Yes')),
+ (0, _('No'))
+ ],
+ initial=0,
+ widget=forms.RadioSelect)
+ retry_duration = forms.ChoiceField(
+ label=_('Limit repeated unsuccessfull logins'),
+ choices=[
+ (0, _('Not at all')),
+ (5, _('Few')),
+ (20, _('A lot'))
+ ],
+ initial=0,
+ widget=forms.RadioSelect)
+ login_form_ou_selector = forms.BooleanField(
+ label=_('Include OU selector on login form'),
+ required=False)
+
+ def save(self):
+ set_setting('A2_USER_REMEMBER_ME', int(
+ self.cleaned_data['remember_me']))
+ set_setting('A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_DURATION',
+ int(self.cleaned_data['retry_duration']))
+ set_setting('A2_LOGIN_FORM_OU_SELECTOR',
+ self.cleaned_data['login_form_ou_selector'])
+
+
+class AuthenticatorsOrderForm(forms.Form):
+ order = forms.CharField(widget=forms.HiddenInput)
diff --git a/src/authentic2/manager/static/authentic2/manager/css/style.css b/src/authentic2/manager/static/authentic2/manager/css/style.css
index 8d692fd..477dcce 100644
--- a/src/authentic2/manager/static/authentic2/manager/css/style.css
+++ b/src/authentic2/manager/static/authentic2/manager/css/style.css
@@ -260,3 +260,27 @@ form .widget span.select2-container {
.refreshing .content table {
opacity: 0.5;
}
+
+/* FranceConnect form's scopes styles */
+
+ul#id_fc-form-scopes,
+ul#id_fc-form-scopes li {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ -moz-column-width: 20em;
+ -webkit-column-width: 20em;
+ column-width: 20em;
+}
+
+span.handle {
+ cursor: move;
+ display: inline-block;
+ padding: 0.5ex;
+ text-align: center;
+ width: 1em;
+}
+
+#authenticators-ordered-list li {
+ padding-left: 0;
+}
diff --git a/src/authentic2/manager/static/authentic2/manager/js/manager.js b/src/authentic2/manager/static/authentic2/manager/js/manager.js
index 0afdf65..2086b77 100644
--- a/src/authentic2/manager/static/authentic2/manager/js/manager.js
+++ b/src/authentic2/manager/static/authentic2/manager/js/manager.js
@@ -1,3 +1,13 @@
+function init_authenticators_list() {
+ $('#authenticators-ordered-list').sortable({
+ handle: '.handle',
+ stop: function(event, ui) {
+ var new_order = $('#authenticators-ordered-list li').map(function() {return $(this).data('authenticator-id');}).get().join();
+ $('#authenticators-ordered-list + input[name="order"]').val(new_order);
+ }
+ });
+}
+
(function($, window, undefined) {
$.fn.values = function(data) {
var els = this.find(':input').get();
@@ -50,6 +60,7 @@
* @argument data {array} If included, will populate all child controls.
* @returns element if data was provided, or array of values if not
*/
+ init_authenticators_list();
/* search inputs behaviours */
$('#search-input').change(function () {
@@ -190,6 +201,7 @@
$(e.target).data('_changed', true);
});
$(document).on('gadjo:dialog-loaded', function (e, form) {
+ init_authenticators_list();
if ($('#id_slug').val()) {
$('#id_slug').data('_changed', true);
}
diff --git a/src/authentic2/manager/templates/authentic2/manager/authenticator_add_form.html b/src/authentic2/manager/templates/authentic2/manager/authenticator_add_form.html
new file mode 100644
index 0000000..8ed6139
--- /dev/null
+++ b/src/authentic2/manager/templates/authentic2/manager/authenticator_add_form.html
@@ -0,0 +1,12 @@
+{% extends "authentic2/manager/form.html" %}
+{% load i18n %}
+
+{% block page_title %}
+{% blocktrans with name=authenticator.name %}Add {{ name }} authentication{% endblocktrans %}
+{% endblock %}
+
+{% block breadcrumb %}
+{{ block.super }}
+<a href="{% url 'a2-manager-authenticators-homepage' %}">{% trans "Authenticators" %}</a>
+<a href="#">{% trans "Add new authenticator" %}</a>
+{% endblock %}
diff --git a/src/authentic2/manager/templates/authentic2/manager/authenticator_management_form.html b/src/authentic2/manager/templates/authentic2/manager/authenticator_management_form.html
new file mode 100644
index 0000000..97f5524
--- /dev/null
+++ b/src/authentic2/manager/templates/authentic2/manager/authenticator_management_form.html
@@ -0,0 +1,12 @@
+{% load i18n gadjo %}
+<div class="section{% if not instance.enabled %} disabled{% endif %}">
+ <h3>{% blocktrans with title=instance.title %}Authentication via {{ title }}{% endblocktrans %}<a class="button" href="{{ action_url }}">{% if instance.enabled %}{% trans "Disable" %}{% else %}{% trans "Enable" %}{% endif %}</a></h3>
+ <div>
+ <form method="post" class="pk-mark-optional-fields">
+ {% csrf_token %}
+ {{ form|with_template }}
+ <button name="{{ form_id }}">{% trans "Save" %}</button>
+ </form>
+ </div>
+</div>
+
diff --git a/src/authentic2/manager/templates/authentic2/manager/authenticators_add_list.html b/src/authentic2/manager/templates/authentic2/manager/authenticators_add_list.html
new file mode 100644
index 0000000..2336419
--- /dev/null
+++ b/src/authentic2/manager/templates/authentic2/manager/authenticators_add_list.html
@@ -0,0 +1,23 @@
+{% extends "authentic2/manager/base.html" %}
+{% load i18n gadjo %}
+
+{% block page-title %}{{ block.super }} - {% trans "Authenticators" %}{% endblock %}
+
+{% block breadcrumb %}
+ {{ block.super }}
+ <a href="#">{% trans "Add new authentication" %}</a>
+{% endblock %}
+
+{% block page_title %}
+ {% trans "Add new authentication" %}
+{% endblock %}
+
+{% block main %}
+ <div class="authenticators">
+ {% for authenticator in authenticators %}
+ <p><a class="button" href="{% url 'a2-manager-authenticator-add' auth_id=authenticator.id %}" rel="popup">
+ {% blocktrans with name=authenticator.name %}{{ name }} authentication{% endblocktrans %}
+ </a></p>
+ {% endfor %}
+</div>
+{% endblock %}
diff --git a/src/authentic2/manager/templates/authentic2/manager/authenticators_home.html b/src/authentic2/manager/templates/authentic2/manager/authenticators_home.html
new file mode 100644
index 0000000..903448d
--- /dev/null
+++ b/src/authentic2/manager/templates/authentic2/manager/authenticators_home.html
@@ -0,0 +1,23 @@
+{% extends "authentic2/manager/base.html" %}
+{% load i18n gadjo %}
+
+{% block page-title %}{{ block.super }} - {% trans "Authenticators" %}{% endblock %}
+
+{% block appbar %}
+ {{ block.super }}
+ <span class="actions">
+ <a href="{% url 'a2-manager-authenticator-add-list' %}" rel="popup" data-selector="div.content">{% trans "Add new authentication" %}</a>
+ <a href="{% url 'a2-manager-authenticators-order-config' %}" rel="popup">{% trans "Configure display order" %}</a>
+ </span>
+{% endblock %}
+
+{% block breadcrumb %}
+ {{ block.super }}
+ <a href="{% url 'a2-manager-services' %}">{% trans 'Authenticators' %}</a>
+{% endblock %}
+
+{% block main %}
+ {% for authenticator_form in authenticators_forms %}
+ {{ authenticator_form.content|safe }}
+ {% endfor %}
+{% endblock %}
diff --git a/src/authentic2/manager/templates/authentic2/manager/authenticators_order.html b/src/authentic2/manager/templates/authentic2/manager/authenticators_order.html
new file mode 100644
index 0000000..6ebd227
--- /dev/null
+++ b/src/authentic2/manager/templates/authentic2/manager/authenticators_order.html
@@ -0,0 +1,12 @@
+{% extends "authentic2/manager/form.html" %}
+{% load i18n %}
+
+{% block beforeform %}
+ <ul class="objects-list" id="authenticators-ordered-list">
+ {% for instance in instances %}
+ <li data-authenticator-id="{{ instance.slug }}">
+ <span class="handle">⣿</span>{{ instance.name }}
+ </li>
+ {% endfor %}
+ </ul>
+{% endblock %}
diff --git a/src/authentic2/manager/templates/authentic2/manager/homepage.html b/src/authentic2/manager/templates/authentic2/manager/homepage.html
index 495c41e..836d062 100644
--- a/src/authentic2/manager/templates/authentic2/manager/homepage.html
+++ b/src/authentic2/manager/templates/authentic2/manager/homepage.html
@@ -12,6 +12,7 @@
<ul class="extra-actions-menu">
<li><a download href="{% url 'a2-manager-site-export' %}">{% trans 'Export Site' %}</a></li>
<li><a href="{% url 'a2-manager-site-import' %}" rel="popup">{% trans 'Import Site' %}</a></li>
+ <li><a href="{% url 'a2-manager-authenticators-homepage' %}">{% trans 'Authentication' %}</a></li>
</ul>
</span>
{% endif %}
diff --git a/src/authentic2/manager/urls.py b/src/authentic2/manager/urls.py
index 5dbd5b6..de56022 100644
--- a/src/authentic2/manager/urls.py
+++ b/src/authentic2/manager/urls.py
@@ -20,6 +20,7 @@ from django.views.i18n import JavaScriptCatalog
from django.contrib.auth.decorators import login_required
from django.utils.functional import lazy
from . import views, role_views, ou_views, user_views, service_views
+from . import authenticators_views
from ..decorators import required
from authentic2 import utils
@@ -152,6 +153,19 @@ urlpatterns = required(
url(r'^services/(?P<service_pk>\d+)/edit/$', service_views.edit,
name='a2-manager-service-edit'),
+ # Authenticators
+ url(r'^authentication/$', authenticators_views.home,
+ name='a2-manager-authenticators-homepage'),
+ url(r'^authentication/add/$', authenticators_views.authenticators_add_list,
+ name='a2-manager-authenticator-add-list'),
+ url(r'^authentication/add/(?P<auth_id>\w*)$', authenticators_views.authenticator_add,
+ name='a2-manager-authenticator-add'),
+ url(r'^authentication/order-config/$', authenticators_views.authenticators_order_config,
+ name='a2-manager-authenticators-order-config'),
+ url(r'^authentication/(?P<instance_slug>[\w-]+)/(?P<action_name>enable|disable)$',
+ authenticators_views.toggle_authenticator_instance,
+ name='a2-manager-authenticator-toggle-instance'),
+
# backoffice menu as json
url(r'^menu.json$', views.menu_json),
diff --git a/src/authentic2/views.py b/src/authentic2/views.py
index d886d5c..a916da1 100644
--- a/src/authentic2/views.py
+++ b/src/authentic2/views.py
@@ -326,7 +326,7 @@ def login(request, template_name='authentic2/login.html',
show_ctx['service'] = service
# check if the authenticator has multiple instances
if hasattr(authenticator, 'instances'):
- for instance_id, instance in authenticator.instances(**parameters):
+ for instance_id, priority, instance in authenticator.instances(**parameters):
parameters['instance'] = instance
parameters['instance_id'] = instance_id
if not authenticator.shown(instance_id=instance_id,
@@ -335,10 +335,13 @@ def login(request, template_name='authentic2/login.html',
block = utils.get_authenticator_method(authenticator, 'login', parameters)
# update block id in order to separate instances
block['id'] = '%s_%s' % (block['id'], instance_id)
+ block['priority'] = priority
auth_blocks.append(block)
else:
if authenticator.shown(ctx=show_ctx):
- auth_blocks.append(utils.get_authenticator_method(authenticator, 'login', parameters))
+ block = utils.get_authenticator_method(authenticator, 'login', parameters)
+ block['priority'] = authenticator.priority
+ auth_blocks.append(block)
# If a login frontend method returns an HttpResponse with a status code != 200
# this response is returned.
for block in auth_blocks:
@@ -346,6 +349,8 @@ def login(request, template_name='authentic2/login.html',
if block['status_code'] != 200:
return block['response']
blocks.append(block)
+ # TODO: sort blocks by priority
+ blocks.sort(key=lambda b: b['priority'])
# run the only available authenticator if able to autorun
if len(blocks) == 1:
diff --git a/src/authentic2_auth_fc/app_settings.py b/src/authentic2_auth_fc/app_settings.py
index 1a7e02d..288ac03 100644
--- a/src/authentic2_auth_fc/app_settings.py
+++ b/src/authentic2_auth_fc/app_settings.py
@@ -139,6 +139,10 @@ class AppSettings(object):
def popup(self):
return self._setting('POPUP', False)
+ @property
+ def priority(self):
+ return self._setting('PRIORITY', -1)
+
app_settings = AppSettings('A2_FC_')
app_settings.__name__ = __name__
sys.modules[__name__] = app_settings
diff --git a/src/authentic2_auth_fc/authenticators.py b/src/authentic2_auth_fc/authenticators.py
index 8391acd..9a1c797 100644
--- a/src/authentic2_auth_fc/authenticators.py
+++ b/src/authentic2_auth_fc/authenticators.py
@@ -18,22 +18,54 @@ from django.utils.translation import gettext_noop
from django.template.loader import render_to_string
from django.template.response import TemplateResponse
+from authentic2.a2_settings import set_setting
from authentic2 import app_settings as a2_app_settings, utils as a2_utils
from authentic2.authenticators import BaseAuthenticator
+from authentic2.authenticators import BaseAuthenticatorManagedInstance
from authentic2.utils import redirect_to_login
from . import app_settings
+from .manager.forms import FcAuthenticatorManagerAddForm
+from .manager.forms import FcAuthenticatorManagerEditForm
+
+
+class FcAuthenticatorManagedInstance(BaseAuthenticatorManagedInstance):
+
+ def get_manager_form_initial_values(self, instance=None):
+ return {
+ 'platform': app_settings.authorize_url,
+ 'client_id': app_settings.client_id,
+ 'client_secret': app_settings.client_secret,
+ 'scopes': app_settings.scopes
+ }
class FcAuthenticator(BaseAuthenticator):
id = 'fc'
- priority = -1
+
+ @property
+ def priority(self):
+ return app_settings.priority
+
+ def set_priority(self, priority):
+ set_setting('A2_FC_PRIORITY', priority)
+
+ def enable(self):
+ set_setting('A2_FC_ENABLE', True)
+
+ def disable(self):
+ set_setting('A2_FC_ENABLE', False)
def enabled(self):
return (app_settings.enable and
app_settings.client_id and
app_settings.client_secret)
+ def is_configured(self):
+ return (app_settings.enable or
+ app_settings.client_id or
+ app_settings.client_secret)
+
def name(self):
return gettext_noop('FranceConnect')
@@ -114,3 +146,12 @@ class FcAuthenticator(BaseAuthenticator):
'about_url': app_settings.about_url,
})
return TemplateResponse(request, 'authentic2_auth_fc/registration.html', context)
+
+ def get_manager_add_form(self):
+ return FcAuthenticatorManagerAddForm
+
+ def get_manager_edit_form(self):
+ return FcAuthenticatorManagerEditForm
+
+ def get_instance_wrapper_class(self):
+ return FcAuthenticatorManagedInstance
diff --git a/src/authentic2_auth_fc/manager/__init__.py b/src/authentic2_auth_fc/manager/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/authentic2_auth_fc/manager/__init__.py
diff --git a/src/authentic2_auth_fc/manager/forms.py b/src/authentic2_auth_fc/manager/forms.py
new file mode 100644
index 0000000..e188904
--- /dev/null
+++ b/src/authentic2_auth_fc/manager/forms.py
@@ -0,0 +1,67 @@
+# authentic2 - versatile identity manager
+# Copyright (C) 2010-2020 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 import forms
+from django.utils.translation import ugettext as _
+
+from authentic2.a2_settings import set_setting
+
+
+class FcAuthenticatorManagerAddForm(forms.Form):
+ css_class = 'pk-mark-optional-fields'
+
+ platform = forms.ChoiceField(
+ label=_('Platform'),
+ choices=[
+ ('https://app.franceconnect.gouv.fr/api/v1/authorize', _('Production')),
+ ('https://fcp.integ01.dev-franceconnect.fr/api/v1/authorize', _('Integration'))
+ ],
+ widget=forms.RadioSelect)
+ client_id = forms.CharField(
+ label=_('Client ID'),
+ help_text=_('See <a href="https://partenaires.franceconnect.gouv.fr/fcp/fournisseur-service">'
+ 'FranceConnect partners site</a> for getting client ID and secret.'))
+ client_secret = forms.CharField(
+ label=_('Client Secret'))
+
+ def save(self):
+ set_setting('A2_FC_AUTHORIZE_URL', self.cleaned_data['platform'])
+ set_setting('A2_FC_CLIENT_ID', self.cleaned_data['client_id'])
+ set_setting('A2_FC_CLIENT_SECRET', self.cleaned_data['client_secret'])
+ set_setting('A2_FC_SCOPES', self.cleaned_data.get('scopes') or ['profile', 'email'])
+ set_setting('A2_FC_ENABLE', True)
+
+
+class FcAuthenticatorManagerEditForm(FcAuthenticatorManagerAddForm):
+ scopes = forms.MultipleChoiceField(
+ label=_('Scopes'),
+ choices=[
+ ('given_name', _('given name (given_name)')),
+ ('gender', _('gender (gender)')),
+ ('birthdate', _('birthdate (birthdate)')),
+ ('birthcountry', _('birthcountry (birthcountry)')),
+ ('birthplace', _('birthplace (birthplace)')),
+ ('family_name', _('family name (family_name)')),
+ ('email', _('email (email)')),
+ ('preferred_username', _('usual family name (preferred_username)')),
+ ('address', _('address (address)')),
+ ('phone', _('phone (phone)')),
+ ('identite_pivot', _('identite_pivot (identite_pivot)')),
+ ('profile', _('profile (profile)')),
+ ('birth', _('birth profile (birth)')),
+ ],
+ widget=forms.CheckboxSelectMultiple,
+ help_text=_('These scopes will be requested in addition to openid'))
diff --git a/src/authentic2_auth_oidc/app_settings.py b/src/authentic2_auth_oidc/app_settings.py
index e998d8b..5964510 100644
--- a/src/authentic2_auth_oidc/app_settings.py
+++ b/src/authentic2_auth_oidc/app_settings.py
@@ -37,6 +37,10 @@ class AppSettings(object):
def ENABLE(self):
return self._setting('ENABLE', True)
+ @property
+ def priority(self):
+ return self._setting('PRIORITY', 2)
+
app_settings = AppSettings('A2_AUTH_OIDC_')
app_settings.__name__ = __name__
sys.modules[__name__] = app_settings
diff --git a/src/authentic2_auth_oidc/authenticators.py b/src/authentic2_auth_oidc/authenticators.py
index 2316dd0..74cd80c 100644
--- a/src/authentic2_auth_oidc/authenticators.py
+++ b/src/authentic2_auth_oidc/authenticators.py
@@ -22,21 +22,82 @@ from .models import OIDCProvider
from authentic2.utils import make_url
from authentic2.utils import redirect_to_login
from authentic2.authenticators import BaseAuthenticator
+from authentic2.authenticators import BaseAuthenticatorManagedInstance
+
+from .manager.forms import OIDCAuthenticatorManagerAddForm
+from .manager.forms import OIDCAuthenticatorManagerEditForm
+
+
+class OIDCAuthenticatorManagedInstance(BaseAuthenticatorManagedInstance):
+
+ @property
+ def name(self):
+ return self.instance.name
+
+ @property
+ def slug(self):
+ return '%s-%s' % (super().slug, self.instance.slug)
+
+ @property
+ def title(self):
+ return '%s (%s)' % (self.authenticator.name(), self.name)
+
+ @property
+ def priority(self):
+ return self.instance.priority
+
+ def enabled(self):
+ return self.instance.show
+
+ def get_manager_form_initial_values(self):
+ return {}
class OIDCAuthenticator(BaseAuthenticator):
id = 'oidc'
- priority = 2
+
+ @property
+ def priority(self):
+ return app_settings.priority
+
+ def set_instance_priority(self, slug, priority):
+ for s, idx, instance in self.instances():
+ if instance.slug == slug:
+ instance.priority = priority
+ instance.save()
+ break
def enabled(self):
return app_settings.ENABLE and utils.has_providers()
+ def is_configured(self):
+ return app_settings.ENABLE or utils.has_providers()
+
def name(self):
return gettext_noop('OpenIDConnect')
- def instances(self, **kwargs):
- for p in utils.get_providers(shown=True):
- yield (p.slug, p)
+ def instances(self, shown=True, **kwargs):
+ for p in utils.get_providers(shown=shown):
+ yield (p.slug, p.priority, p)
+
+ def instance_enabled(self, instance_slug):
+ qs = utils.get_providers(shown=True)
+ return qs.filter(slug=instance_slug).exists()
+
+ def instance_toggle(self, instance_slug, qs, enabled=True):
+ instance = qs.get(slug=instance_slug)
+ qs.filter(slug=instance_slug).update(show=enabled)
+
+ def instance_enable(self, instance_slug):
+ qs = utils.get_providers(shown=False)
+ self.instance_toggle(instance_slug, qs)
+
+ def instance_disable(self, instance_slug):
+ qs = utils.get_providers(shown=True)
+ self.instance_toggle(instance_slug, qs, False)
+
+ def get_instance_wrapper_class(self):
+ return OIDCAuthenticatorManagedInstance
def autorun(self, request, block_id):
auth_id, instance_slug = block_id.split('_')
@@ -58,3 +119,9 @@ class OIDCAuthenticator(BaseAuthenticator):
template_names = ['authentic2_auth_oidc/login_%s.html' % instance.slug,
'authentic2_auth_oidc/login.html']
return render(request, template_names, context)
+
+ def get_manager_add_form(self):
+ return OIDCAuthenticatorManagerAddForm
+
+ def get_manager_edit_form(self):
+ return OIDCAuthenticatorManagerEditForm
diff --git a/src/authentic2_auth_oidc/manager/__init__.py b/src/authentic2_auth_oidc/manager/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/authentic2_auth_oidc/manager/__init__.py
diff --git a/src/authentic2_auth_oidc/manager/forms.py b/src/authentic2_auth_oidc/manager/forms.py
new file mode 100644
index 0000000..880ab87
--- /dev/null
+++ b/src/authentic2_auth_oidc/manager/forms.py
@@ -0,0 +1,54 @@
+# authentic2 - versatile identity manager
+# Copyright (C) 2010-2020 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 import forms
+from django.utils.text import slugify
+
+from authentic2.a2_rbac.utils import get_default_ou
+from authentic2_auth_oidc.models import OIDCProvider
+from authentic2_auth_oidc import app_settings
+
+
+class OIDCAuthenticatorManagerAddForm(forms.ModelForm):
+ css_class = 'pk-mark-optional-fields'
+
+ class Meta:
+ model = OIDCProvider
+ exclude = ('slug', 'scopes', 'jwkset_json', 'end_session_endpoint',
+ 'token_revocation_endpoint', 'claims_parameter_supported',
+ 'max_auth_age', 'idtoken_algo', 'strategy', 'ou', 'priority',
+ 'show')
+
+ def save(self, **kwargs):
+ self.instance.slug = slugify(self.instance.name)
+ self.instance.strategy = OIDCProvider.STRATEGY_CREATE
+ self.instance.ou = get_default_ou()
+ self.instance.priority = app_settings.priority
+ return super().save(**kwargs)
+
+
+class OIDCAuthenticatorManagerEditForm(forms.ModelForm):
+ class Meta:
+ model = OIDCProvider
+ exclude = ('scopes', 'jwkset_json', 'end_session_endpoint',
+ 'token_revocation_endpoint', 'claims_parameter_supported',
+ 'max_auth_age', 'show', 'priority')
+
+ def __init__(self, *args, **kwargs):
+ instance = kwargs.pop('instance', None)
+ if instance:
+ kwargs['instance'] = instance.instance
+ super().__init__(**kwargs)
diff --git a/src/authentic2_auth_oidc/migrations/0008_oidcprovider_priority.py b/src/authentic2_auth_oidc/migrations/0008_oidcprovider_priority.py
new file mode 100644
index 0000000..95f2eaf
--- /dev/null
+++ b/src/authentic2_auth_oidc/migrations/0008_oidcprovider_priority.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.29 on 2020-11-02 12:43
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('authentic2_auth_oidc', '0007_auto_20200317_1732'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='oidcprovider',
+ name='priority',
+ field=models.PositiveIntegerField(default=2, verbose_name='priority on login page'),
+ ),
+ ]
diff --git a/src/authentic2_auth_oidc/models.py b/src/authentic2_auth_oidc/models.py
index 3a894c8..3722191 100644
--- a/src/authentic2_auth_oidc/models.py
+++ b/src/authentic2_auth_oidc/models.py
@@ -142,6 +142,11 @@ class OIDCProvider(models.Model):
blank=True,
default=True)
+ # priority on login page
+ priority = models.PositiveIntegerField(
+ verbose_name=_('priority on login page'),
+ default=2)
+
# metadata
created = models.DateTimeField(
verbose_name=_('created'),
diff --git a/src/authentic2_auth_saml/adapters.py b/src/authentic2_auth_saml/adapters.py
index 99227ec..0d4e67c 100644
--- a/src/authentic2_auth_saml/adapters.py
+++ b/src/authentic2_auth_saml/adapters.py
@@ -29,6 +29,8 @@ from authentic2 import utils
from authentic2.utils.evaluate import evaluate_condition
from authentic2.a2_rbac.models import Role, OrganizationalUnit as OU
+from . import app_settings
+
logger = logging.getLogger('authentic2.auth_saml')
@@ -63,6 +65,10 @@ class SamlConditionContextProxy(object):
class AuthenticAdapter(DefaultAdapter):
+
+ def get_identity_providers_setting(self):
+ return app_settings.identity_providers
+
def create_user(self, user_class):
return user_class.objects.create()
diff --git a/src/authentic2_auth_saml/app_settings.py b/src/authentic2_auth_saml/app_settings.py
index adbcab7..c84e92f 100644
--- a/src/authentic2_auth_saml/app_settings.py
+++ b/src/authentic2_auth_saml/app_settings.py
@@ -16,6 +16,10 @@
import sys
+from authentic2.a2_settings import get_setting
+
+from mellon import app_settings as mellon_settings
+
class AppSettings(object):
'''Thanks django-allauth'''
@@ -28,6 +32,10 @@ class AppSettings(object):
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
+ a2_setting = get_setting(self.prefix + name)
+ if a2_setting:
+ return a2_setting.value
+
v = getattr(settings, self.prefix + name, dflt)
if v is self.__SENTINEL:
raise ImproperlyConfigured('Missing setting %r' % (self.prefix + name))
@@ -37,6 +45,14 @@ class AppSettings(object):
def enable(self):
return self._setting('ENABLE', False)
+ @property
+ def identity_providers(self):
+ return self._setting('MELLON_IDENTITY_PROVIDERS', mellon_settings.IDENTITY_PROVIDERS)
+
+ @property
+ def priority(self):
+ return self._setting('PRIORITY', 3)
+
app_settings = AppSettings('A2_AUTH_SAML_')
app_settings.__name__ = __name__
sys.modules[__name__] = app_settings
diff --git a/src/authentic2_auth_saml/authenticators.py b/src/authentic2_auth_saml/authenticators.py
index 275f935..e063766 100644
--- a/src/authentic2_auth_saml/authenticators.py
+++ b/src/authentic2_auth_saml/authenticators.py
@@ -14,42 +14,115 @@
# 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 json
+
+from django.conf import settings
from django.utils.translation import gettext_noop
from django.template.loader import render_to_string
from django.shortcuts import render
from mellon.utils import get_idp, get_idps
from authentic2.authenticators import BaseAuthenticator
+from authentic2.authenticators import BaseAuthenticatorManagedInstance
+from authentic2.a2_settings import set_setting
from authentic2.utils import redirect_to_login
from . import app_settings
+from .manager.forms import SAMLAuthenticatorManagerAddForm
+from .manager.forms import SAMLAuthenticatorManagerEditForm
+
+
+class SAMLAuthenticatorManagedInstance(BaseAuthenticatorManagedInstance):
+
+ @property
+ def name(self):
+ return self.instance.get('NAME', '')
+
+ @property
+ def slug(self):
+ return '%s-%s' % (super().slug, self.instance.get('SLUG', ''))
+
+ @property
+ def title(self):
+ return '%s (%s)' % (self.authenticator.name(), self.name)
+
+ def enabled(self):
+ return self.instance.get('ENABLED', True)
+
+ @property
+ def priority(self):
+ return self.instance.get('PRIORITY', self.authenticator.priority)
+
+ def get_manager_form_initial_values(self):
+ return {
+ 'name': self.instance.get('NAME'),
+ 'slug': self.instance.get('SLUG'),
+ 'metadata': self.instance.get('METADATA'),
+ 'provision': self.instance.get('PROVISION', False),
+ 'attribute_mapping': json.dumps(self.instance.get('A2_ATTRIBUTE_MAPPING', '') or []),
+ 'lookup_by_attributes': json.dumps(self.instance.get('LOOKUP_BY_ATTRIBUTES', '') or [])
+ }
class SAMLAuthenticator(BaseAuthenticator):
id = 'saml'
- priority = 3
+
+ @property
+ def priority(self):
+ return app_settings.priority
+
+ def set_instance_priority(self, slug, priority):
+ instances = app_settings.identity_providers
+ for instance in instances:
+ if instance['SLUG'] == slug:
+ instance['PRIORITY'] = priority
+ set_setting('A2_AUTH_SAML_MELLON_IDENTITY_PROVIDERS', instances)
def enabled(self):
return app_settings.enable and list(get_idps())
+ def is_configured(self):
+ return app_settings.enable or list(get_idps())
+
def name(self):
return gettext_noop('SAML')
def instances(self, **kwargs):
for idx, idp in enumerate(get_idps()):
- yield(idp.get('SLUG') or str(idx), idp)
+ yield(idp.get('SLUG') or str(idx), idp.get('PRIORITY', self.priority), idp)
+
+ def instance_enabled(self, instance_slug):
+ for idp in get_idps():
+ if idp.get('SLUG') == instance_slug and idp.get('ENABLED', True):
+ return True
+ return False
+
+ def instance_toggle(self, instance_slug, enabled=True):
+ instances = app_settings.identity_providers
+ for i in instances:
+ if i.get('SLUG') == instance_slug:
+ i['ENABLED'] = enabled
+ set_setting('A2_AUTH_SAML_MELLON_IDENTITY_PROVIDERS', instances)
+
+ def instance_enable(self, instance_slug):
+ self.instance_toggle(instance_slug)
+
+ def instance_disable(self, instance_slug):
+ self.instance_toggle(instance_slug, False)
+
+ def get_instance_wrapper_class(self):
+ return SAMLAuthenticatorManagedInstance
def autorun(self, request, block_id):
auth_id, instance_slug = block_id.split('_')
assert auth_id == self.id
- for slug, instance in self.instances():
+ for slug, priority, instance in self.instances():
if slug == instance_slug:
return redirect_to_login(request, login_url='mellon_login',
params={'entityID': instance['ENTITY_ID']})
return redirect_to_login(request)
-
def login(self, request, *args, **kwargs):
context = kwargs.pop('context', {})
instance_id = kwargs.get('instance_id')
@@ -72,3 +145,9 @@ class SAMLAuthenticator(BaseAuthenticator):
context['user_saml_identifiers'] = user_saml_identifiers
return render_to_string('authentic2_auth_saml/profile.html',
context, request=request)
+
+ def get_manager_add_form(self):
+ return SAMLAuthenticatorManagerAddForm
+
+ def get_manager_edit_form(self):
+ return SAMLAuthenticatorManagerEditForm
diff --git a/src/authentic2_auth_saml/manager/__init__.py b/src/authentic2_auth_saml/manager/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/authentic2_auth_saml/manager/__init__.py
diff --git a/src/authentic2_auth_saml/manager/forms.py b/src/authentic2_auth_saml/manager/forms.py
new file mode 100644
index 0000000..c864207
--- /dev/null
+++ b/src/authentic2_auth_saml/manager/forms.py
@@ -0,0 +1,95 @@
+# authentic2 - versatile identity manager
+# Copyright (C) 2010-2020 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 json
+
+from django import forms
+from django.utils.translation import ugettext as _
+
+from authentic2_auth_saml import app_settings
+from authentic2_auth_saml.adapters import AuthenticAdapter
+
+from authentic2.a2_settings import set_setting
+
+
+def json_validation(value):
+ if value:
+ try:
+ json.loads(value)
+ except json.JSONDecodeError as e:
+ raise forms.ValidationError(_('Invalid JSON: %s') % e)
+
+
+class SAMLAuthenticatorManagerAddForm(forms.Form):
+ css_class = 'pk-mark-optional-fields'
+
+ name = forms.CharField(label=_('Name'))
+ slug = forms.CharField(label=_('Slug'))
+ metadata = forms.CharField(label=_('Metadata'),
+ widget=forms.Textarea(attrs={'rows': 7}))
+
+ def __init__(self, **kwargs):
+ instance = kwargs.pop('instance', None)
+ super().__init__(**kwargs)
+
+ def clean_metadata(self):
+ metadata = self.cleaned_data['metadata']
+ adapter = AuthenticAdapter()
+ if not adapter.load_entity_id(metadata, 0):
+ raise forms.ValidationError('Invalid metadata')
+ return metadata
+
+ def update_instance(self, instance):
+ instance['NAME'] = self.cleaned_data['name']
+ instance['SLUG'] = self.cleaned_data['slug']
+ instance['PROVISION'] = self.cleaned_data.get('provision', True)
+ instance['METADATA'] = self.cleaned_data['metadata']
+ if self.cleaned_data.get('attribute_mapping'):
+ instance['A2_ATTRIBUTE_MAPPING'] = json.loads(
+ self.cleaned_data['attribute_mapping'])
+ if self.cleaned_data.get('lookup_by_attributes'):
+ instance['LOOKUP_BY_ATTRIBUTES'] = json.loads(
+ self.cleaned_data['lookup_by_attributes'])
+
+ def save(self, **kwargs):
+ instances = app_settings.identity_providers
+ instance = {}
+ self.update_instance(instance)
+ instances.append(instance)
+ set_setting('A2_AUTH_SAML_MELLON_IDENTITY_PROVIDERS', instances)
+ set_setting('A2_AUTH_SAML_ENABLE', True)
+
+
+class SAMLAuthenticatorManagerEditForm(SAMLAuthenticatorManagerAddForm):
+ provision = forms.BooleanField(label=_('Provision'), required=False)
+ attribute_mapping = forms.CharField(label=_('Attribute mapping'),
+ help_text=_('JSON formatted'),
+ required=False,
+ validators=[json_validation],
+ widget=forms.Textarea(attrs={'rows': 2}))
+ lookup_by_attributes = forms.CharField(label=_('Lookup by attributes'),
+ help_text=_('JSON formatted'),
+ required=False,
+ validators=[json_validation],
+ widget=forms.Textarea(attrs={'rows': 2}))
+
+ def save(self, **kwargs):
+ instances = app_settings.identity_providers
+ for instance in instances:
+ if self.cleaned_data['slug'] == instance['SLUG']:
+ self.update_instance(instance)
+ break
+ set_setting('A2_AUTH_SAML_MELLON_IDENTITY_PROVIDERS', instances)
diff --git a/tests/test_login.py b/tests/test_login.py
index 97a83c2..d24895c 100644
--- a/tests/test_login.py
+++ b/tests/test_login.py
@@ -306,3 +306,16 @@ def test_login_opened_session_cookie(db, app, settings, simple_user):
for cookie in app.cookiejar:
if cookie.name == 'A2_OPENED_SESSION':
assert cookie.secure is True
+
+
+def test_login_blocks_order(db, app, settings):
+ settings.A2_FC_ENABLE = True
+ settings.A2_FC_CLIENT_ID = 'client_id'
+ settings.A2_FC_CLIENT_SECRET = 'secret'
+ response = app.get('/login/')
+ assert response.text.index('FranceConnect') < response.text.index('Password')
+
+ settings.A2_FC_PRIORITY = 10
+ settings.A2_LOGIN_FORM_PRIORITY = 1
+ response = app.get('/login/')
+ assert response.text.index('FranceConnect') > response.text.index('Password')
diff --git a/tests/test_manager.py b/tests/test_manager.py
index 68c49ec..39d19fb 100644
--- a/tests/test_manager.py
+++ b/tests/test_manager.py
@@ -28,6 +28,7 @@ from webtest import Upload
from authentic2.a2_rbac.models import MANAGE_MEMBERS_OP
from authentic2.a2_rbac.utils import get_default_ou
+from authentic2.a2_settings import get_setting
from authentic2.validators import EmailValidator
from django_rbac.utils import (get_ou_model, get_role_model,
@@ -727,10 +728,11 @@ def test_manager_site_import_forbidden(app, simple_user):
app.get('/manage/site-import/', status=403)
-def test_manager_homepage_import_export(superuser, app):
+def test_manager_homepage_authentication_import_export(superuser, app):
manager_home_page = login(app, superuser, reverse('a2-manager-homepage'))
assert 'site-import' in manager_home_page.text
assert 'site-export' in manager_home_page.text
+ assert 'authentication' in manager_home_page.text
def test_manager_homepage_import_export_hidden(admin, app):
@@ -1127,3 +1129,92 @@ def test_display_parent_roles_on_role_page(app, superuser, settings):
assert 'Parent roles:' in parent_roles_html.text
assert [x.text.strip() for x in parent_roles_html.find_all('a', {'class': 'role'})] == \
['parent1', 'ou1 - parent2', 'ou1 - parent4', 'ou2 - parent3']
+
+
+def test_login_password_authentication_management(app, superuser, settings):
+ settings.AUTH_FRONTENDS_KWARGS = {}
+
+ response = login(app, superuser, reverse('a2-manager-homepage'))
+ response = response.click('Authentication')
+ assert 'Authentication via Password' in response.text
+
+ # by default the authentication should be enabled
+ assert 'Enable' not in response.text
+ assert 'Disable' in response.text
+ assert not get_setting('A2_AUTH_PASSWORD_ENABLE')
+
+ response = response.click('Disable').follow()
+ assert 'Enable' in response.text
+ assert 'Disable' not in response.text
+
+ assert not get_setting('A2_AUTH_PASSWORD_ENABLE').value
+
+ response = response.click('Enable').follow()
+ assert 'Enable' not in response.text
+ assert 'Disable' in response.text
+ assert get_setting('A2_AUTH_PASSWORD_ENABLE').value
+
+ assert not get_setting('A2_USER_REMEMBER_ME')
+ assert not get_setting('A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_DURATION')
+
+ assert len(response.forms) == 1
+ form = response.form
+ form.set('password-form-remember_me', '2592000')
+ form.set('password-form-login_form_ou_selector', True)
+ response = form.submit('password-form').follow()
+
+ assert get_setting('A2_USER_REMEMBER_ME').value == 2592000
+ assert get_setting('A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_DURATION').value == 0
+
+ form = response.form
+ assert form['password-form-remember_me'].value == '2592000'
+ assert form['password-form-retry_duration'].value == '0'
+ assert form['password-form-login_form_ou_selector'].value == 'on'
+
+
+def test_add_new_authentication(app, superuser, settings):
+ settings.AUTH_FRONTENDS_KWARGS = {}
+ response = login(app, superuser, reverse('a2-manager-authenticators-homepage'))
+ assert 'Add new authentication' in response.text
+ response = response.click('Add new authentication')
+ assert 'FranceConnect authentication' in response.text
+ assert 'OpenIDConnect authentication' in response.text
+ assert 'SAML authentication' in response.text
+
+ response = response.click('FranceConnect authentication')
+ form = response.form
+ form.set('platform', form['platform'].options[0][0])
+ form.set('client_id', 'client_id')
+ form.set('client_secret', 'secret')
+ response = form.submit().follow()
+
+ assert 'Authentication via FranceConnect added' in response.text
+ # check default order applies
+ assert response.text.index('FranceConnect') < response.text.index('Password')
+
+ assert len(response.forms) == 2
+ assert len(response.pyquery('a.button[href*=disable]')) == 2
+
+ response = app.get(reverse('a2-manager-authenticator-add-list'))
+ assert 'FranceConnect authentication' not in response.text
+ assert 'OpenIDConnect authentication' in response.text
+ assert 'SAML authentication' in response.text
+
+
+def test_reorder_authenticators(app, superuser, settings):
+ settings.AUTH_FRONTENDS_KWARGS = {}
+ settings.A2_FC_ENABLE = True
+ settings.A2_FC_CLIENT_ID = 'client_id'
+ settings.A2_FC_CLIENT_SECRET = 'secret'
+ response = login(app, superuser, reverse('a2-manager-authenticators-homepage'))
+
+ assert response.text.index('FranceConnect') < response.text.index('Password')
+ response = response.click('Configure display order')
+ form = response.form
+ assert form['order'].value == 'fc,password'
+
+ form.set('order', 'password,fc')
+ response = form.submit().follow()
+ assert response.text.index('FranceConnect') > response.text.index('Password')
+ assert get_setting('A2_LOGIN_FORM_PRIORITY').value == 0
+ assert get_setting('A2_FC_PRIORITY').value == 1