From 7eceb2e9c524616c6e1f2a05b5dde4b268d92bbd Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Wed, 13 Apr 2022 13:58:05 +0200 Subject: [PATCH 2/4] authenticators: add new app (#53902) --- .../apps/authenticators/__init__.py | 0 src/authentic2/apps/authenticators/forms.py | 40 ++++++ .../apps/authenticators/manager_urls.py | 75 +++++++++++ .../authenticators/migrations/0001_initial.py | 82 ++++++++++++ .../authenticators/migrations/__init__.py | 0 src/authentic2/apps/authenticators/models.py | 120 ++++++++++++++++++ src/authentic2/apps/authenticators/query.py | 23 ++++ .../authenticator_add_form.html | 18 +++ .../authenticators/authenticator_common.html | 11 ++ .../authenticator_delete_form.html | 19 +++ .../authenticators/authenticator_detail.html | 30 +++++ .../authenticator_edit_form.html | 19 +++ .../authenticators/authenticators.html | 24 ++++ src/authentic2/apps/authenticators/views.py | 105 +++++++++++++++ src/authentic2/authenticators.py | 3 + src/authentic2/forms/mixins.py | 24 ++++ src/authentic2/manager/forms.py | 22 +--- .../authentic2/manager/homepage.html | 1 + src/authentic2/manager/urls.py | 3 + src/authentic2/settings.py | 1 + src/authentic2/utils/misc.py | 7 +- src/authentic2/views.py | 4 +- tests/test_manager_authenticators.py | 29 +++++ 23 files changed, 636 insertions(+), 24 deletions(-) create mode 100644 src/authentic2/apps/authenticators/__init__.py create mode 100644 src/authentic2/apps/authenticators/forms.py create mode 100644 src/authentic2/apps/authenticators/manager_urls.py create mode 100644 src/authentic2/apps/authenticators/migrations/0001_initial.py create mode 100644 src/authentic2/apps/authenticators/migrations/__init__.py create mode 100644 src/authentic2/apps/authenticators/models.py create mode 100644 src/authentic2/apps/authenticators/query.py create mode 100644 src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_add_form.html create mode 100644 src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_common.html create mode 100644 src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_delete_form.html create mode 100644 src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_detail.html create mode 100644 src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_edit_form.html create mode 100644 src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticators.html create mode 100644 src/authentic2/apps/authenticators/views.py create mode 100644 tests/test_manager_authenticators.py diff --git a/src/authentic2/apps/authenticators/__init__.py b/src/authentic2/apps/authenticators/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/authentic2/apps/authenticators/forms.py b/src/authentic2/apps/authenticators/forms.py new file mode 100644 index 000000000..86b69cfcf --- /dev/null +++ b/src/authentic2/apps/authenticators/forms.py @@ -0,0 +1,40 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2022 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 . + +from django import forms + +from authentic2.forms.mixins import SlugMixin + +from .models import BaseAuthenticator + + +class AuthenticatorAddForm(SlugMixin, forms.ModelForm): + field_order = ('authenticator', 'name', 'ou') + authenticators = {x.type: x for x in BaseAuthenticator.__subclasses__()} + + authenticator = forms.ChoiceField(choices=[(k, v._meta.verbose_name) for k, v in authenticators.items()]) + + class Meta: + model = BaseAuthenticator + fields = ('name', 'ou') + + def save(self): + Authenticator = self.authenticators[self.cleaned_data['authenticator']] + self.instance = Authenticator(name=self.cleaned_data['name'], ou=self.cleaned_data['ou']) + return super().save() + + def get_existing_objects_queryset(self): + return BaseAuthenticator.objects.filter(ou=self.cleaned_data['ou']) diff --git a/src/authentic2/apps/authenticators/manager_urls.py b/src/authentic2/apps/authenticators/manager_urls.py new file mode 100644 index 000000000..09aceb2b5 --- /dev/null +++ b/src/authentic2/apps/authenticators/manager_urls.py @@ -0,0 +1,75 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2010-2022 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 . + +from django.contrib.auth.decorators import user_passes_test +from django.core.exceptions import PermissionDenied +from django.urls import path +from django.utils.functional import lazy + +from authentic2.decorators import required +from authentic2.utils import misc as utils_misc + +from . import views + + +def superuser_required(function, login_url): + def check_superuser(user): + if user and user.is_superuser: + return True + if user and not user.is_anonymous: + raise PermissionDenied() + return False + + actual_decorator = user_passes_test(check_superuser, login_url=login_url) + return actual_decorator(function) + + +def superuser_login_required(func): + return superuser_required(func, login_url=lazy(utils_misc.get_manager_login_url, str)()) + + +urlpatterns = required( + superuser_login_required, + [ + # Authenticators + path('authenticators/', views.authenticators, name='a2-manager-authenticators'), + path( + 'authenticators/add/', + views.add, + name='a2-manager-authenticator-add', + ), + path( + 'authenticators//detail/', + views.detail, + name='a2-manager-authenticator-detail', + ), + path( + 'authenticators//edit/', + views.edit, + name='a2-manager-authenticator-edit', + ), + path( + 'authenticators//delete/', + views.delete, + name='a2-manager-authenticator-delete', + ), + path( + 'authenticators//toggle/', + views.toggle, + name='a2-manager-authenticator-toggle', + ), + ], +) diff --git a/src/authentic2/apps/authenticators/migrations/0001_initial.py b/src/authentic2/apps/authenticators/migrations/0001_initial.py new file mode 100644 index 000000000..deca41cff --- /dev/null +++ b/src/authentic2/apps/authenticators/migrations/0001_initial.py @@ -0,0 +1,82 @@ +# Generated by Django 2.2.28 on 2022-04-13 12:09 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.RBAC_OU_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BaseAuthenticator', + fields=[ + ( + 'id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ( + 'uuid', + models.CharField( + default=uuid.uuid4, + max_length=255, + unique=True, + editable=False, + ), + ), + ('name', models.CharField(max_length=128, verbose_name='Name')), + ('slug', models.SlugField()), + ('order', models.IntegerField(default=0, verbose_name='Order')), + ('enabled', models.BooleanField(default=False, editable=False)), + ( + 'show_condition', + models.CharField( + blank=True, + help_text=( + 'Django template controlling authenticator display. ' + 'For example, "\'backoffice\' in login_hint or remotre_addr ' + '== \'1.2.3.4\'" would hide the authenticator from normal users ' + 'except if they come from the specified IP address. Available ' + 'variables include service_ou_slug, service_slug, remote_addr, ' + 'login_hint and headers.' + ), + max_length=128, + verbose_name='Show condition', + ), + ), + ( + 'ou', + models.ForeignKey( + blank=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.RBAC_OU_MODEL, + verbose_name='organizational unit', + ), + ), + ], + options={ + 'ordering': ('-enabled', 'name', 'slug', 'ou'), + }, + ), + migrations.AddConstraint( + model_name='baseauthenticator', + constraint=models.UniqueConstraint( + condition=models.Q(ou__isnull=False), fields=('slug', 'ou'), name='unique_slug_with_ou' + ), + ), + migrations.AddConstraint( + model_name='baseauthenticator', + constraint=models.UniqueConstraint( + condition=models.Q(ou__isnull=True), fields=('slug',), name='unique_slug_without_ou' + ), + ), + ] diff --git a/src/authentic2/apps/authenticators/migrations/__init__.py b/src/authentic2/apps/authenticators/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/authentic2/apps/authenticators/models.py b/src/authentic2/apps/authenticators/models.py new file mode 100644 index 000000000..1776ffa35 --- /dev/null +++ b/src/authentic2/apps/authenticators/models.py @@ -0,0 +1,120 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2022 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 . + +import datetime +import logging +import uuid + +from django.db import models +from django.shortcuts import render, reverse +from django.utils.formats import date_format +from django.utils.translation import ugettext_lazy as _ + +from authentic2.utils.evaluate import evaluate_condition + +from .query import AuthenticatorManager + +logger = logging.getLogger(__name__) + + +class BaseAuthenticator(models.Model): + uuid = models.CharField(max_length=255, unique=True, default=uuid.uuid4, editable=False) + name = models.CharField(_('Name'), max_length=128) + slug = models.SlugField() + ou = models.ForeignKey( + verbose_name=_('organizational unit'), + to='a2_rbac.OrganizationalUnit', + null=True, + blank=False, + on_delete=models.CASCADE, + ) + order = models.IntegerField(_('Order'), default=0) + enabled = models.BooleanField(default=False, editable=False) + show_condition = models.CharField( + _('Show condition'), + max_length=128, + blank=True, + help_text=_( + 'Django template controlling authenticator display. For example, "\'backoffice\' in ' + 'login_hint or remotre_addr == \'1.2.3.4\'" would hide the authenticator from normal users ' + 'except if they come from the specified IP address. Available variables include ' + 'service_ou_slug, service_slug, remote_addr, login_hint and headers.' + ), + ) + + objects = models.Manager() + authenticators = AuthenticatorManager() + + type = '' + manager_form_class = None + description_fields = ['show_condition'] + + class Meta: + ordering = ('-enabled', 'name', 'slug', 'ou') + constraints = [ + models.UniqueConstraint( + fields=['slug', 'ou'], + name='unique_slug_with_ou', + condition=models.Q(ou__isnull=False), + ), + models.UniqueConstraint( + fields=['slug'], + name='unique_slug_without_ou', + condition=models.Q(ou__isnull=True), + ), + ] + + def __str__(self): + if self.name: + return '%s - %s' % (self._meta.verbose_name, self.name) + return str(self._meta.verbose_name) + + def get_identifier(self): + return '%s_%s' % (self.type, self.pk) + + def get_absolute_url(self): + return reverse('a2-manager-authenticator-detail', kwargs={'pk': self.pk}) + + def get_short_description(self): + return '' + + def get_full_description(self): + for field in self.description_fields: + value = getattr(self, field) + if not value: + continue + + if isinstance(value, datetime.datetime): + value = date_format(value, 'DATETIME_FORMAT') + + yield _('%(field)s: %(value)s') % { + 'field': self._meta.get_field(field).verbose_name.capitalize(), + 'value': value, + } + + @property + def priority(self): + return self.order + + def shown(self, ctx=()): + if not self.show_condition: + return True + ctx = dict(ctx, id=self.slug) + try: + return evaluate_condition(self.show_condition, ctx, on_raise=True) + except Exception as e: + logger.error(e) + return False diff --git a/src/authentic2/apps/authenticators/query.py b/src/authentic2/apps/authenticators/query.py new file mode 100644 index 000000000..63b0830ba --- /dev/null +++ b/src/authentic2/apps/authenticators/query.py @@ -0,0 +1,23 @@ +from django.db import models +from django.db.models.query import ModelIterable + + +class AuthenticatorIterable(ModelIterable): + def __iter__(self): + for obj in ModelIterable(self.queryset): + yield next(getattr(obj, field) for field in self.queryset.subclasses if hasattr(obj, field)) + + +class AuthenticatorQuerySet(models.QuerySet): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.subclasses = [ + field.name for field in self.model._meta.get_fields() if isinstance(field, models.OneToOneRel) + ] + self._iterable_class = AuthenticatorIterable + + +class AuthenticatorManager(models.Manager): + def get_queryset(self): + qs = AuthenticatorQuerySet(self.model, using=self._db) + return qs.select_related(*qs.subclasses) diff --git a/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_add_form.html b/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_add_form.html new file mode 100644 index 000000000..4d47fb32b --- /dev/null +++ b/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_add_form.html @@ -0,0 +1,18 @@ +{% extends "authentic2/authenticators/authenticator_common.html" %} +{% load gadjo i18n %} + +{% block breadcrumb %} + {{ block.super }} + +{% endblock %} + +{% block content %} +
+ {% csrf_token %} + {{ form|with_template }} +
+ + {% trans 'Cancel' %} +
+
+{% endblock %} diff --git a/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_common.html b/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_common.html new file mode 100644 index 000000000..b05883c91 --- /dev/null +++ b/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_common.html @@ -0,0 +1,11 @@ +{% extends "authentic2/manager/base.html" %} +{% load i18n %} + +{% block page-title %}{{ block.super }} - {% if object %}{{ object }}{% else %}{% trans "Authenticators" %}{% endif %}{% endblock %} + +{% block title %}{{ block.super }} - {% trans "Authenticators" %}{% endblock %} + +{% block breadcrumb %} + {{ block.super }} + {% trans "Authenticators" %} +{% endblock %} diff --git a/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_delete_form.html b/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_delete_form.html new file mode 100644 index 000000000..754975090 --- /dev/null +++ b/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_delete_form.html @@ -0,0 +1,19 @@ +{% extends "authentic2/authenticators/authenticator_common.html" %} +{% load i18n gadjo %} + +{% block breadcrumb %} + {{ block.super }} + {% trans "Authenticators" %} + +{% endblock %} + +{% block content %} +
+ {% csrf_token %} +

{% blocktrans %}Do you want to delete "{{ object }}" ?{% endblocktrans %}

+
+ + {% trans 'Cancel' %} +
+
+{% endblock %} diff --git a/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_detail.html b/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_detail.html new file mode 100644 index 000000000..846850b2c --- /dev/null +++ b/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_detail.html @@ -0,0 +1,30 @@ +{% extends "authentic2/authenticators/authenticator_common.html" %} +{% load i18n gadjo %} + +{% block appbar %} + {{ block.super }} + + + + {{ object.enabled|yesno:_("Disable,Enable") }} + {% trans "Edit" %} + + +{% endblock %} + +{% block breadcrumb %} + {{ block.super }} + +{% endblock %} + +{% block content %} +
+ {% for line in object.get_full_description %} +

{{ line }}

+ {% empty %} +

{% trans 'Click "Edit" to change configuration.' %}

+ {% endfor %} +
+{% endblock %} diff --git a/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_edit_form.html b/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_edit_form.html new file mode 100644 index 000000000..b81d3dbce --- /dev/null +++ b/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticator_edit_form.html @@ -0,0 +1,19 @@ +{% extends "authentic2/authenticators/authenticator_common.html" %} +{% load i18n gadjo %} + +{% block breadcrumb %} + {{ block.super }} + {{ object }} + +{% endblock %} + +{% block content %} +
+ {% csrf_token %} + {{ form|with_template }} +
+ + {% trans 'Cancel' %} +
+
+{% endblock %} diff --git a/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticators.html b/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticators.html new file mode 100644 index 000000000..3202e86c4 --- /dev/null +++ b/src/authentic2/apps/authenticators/templates/authentic2/authenticators/authenticators.html @@ -0,0 +1,24 @@ +{% extends "authentic2/authenticators/authenticator_common.html" %} +{% load i18n gadjo %} + +{% block appbar %} + {{ block.super }} + + {% trans "Add new authenticator" %} + +{% endblock %} + +{% block main %} + {% for authenticator in object_list %} +
+

{{ authenticator }} + {% trans "Configure" %} +

+ {% if authenticator.enabled and authenticator.get_short_description %} +
+

{{ authenticator.get_short_description }}

+
+ {% endif %} +
+ {% endfor %} +{% endblock %} diff --git a/src/authentic2/apps/authenticators/views.py b/src/authentic2/apps/authenticators/views.py new file mode 100644 index 000000000..35aef34da --- /dev/null +++ b/src/authentic2/apps/authenticators/views.py @@ -0,0 +1,105 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2010-2022 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 . + +from django.contrib import messages +from django.http import HttpResponseRedirect +from django.urls import reverse_lazy +from django.utils.translation import ugettext as _ +from django.views.generic import CreateView, DeleteView, DetailView, UpdateView +from django.views.generic.list import ListView + +from authentic2.apps.authenticators import forms +from authentic2.apps.authenticators.models import BaseAuthenticator +from authentic2.manager.views import MediaMixin, TitleMixin + + +class AuthenticatorsMixin(MediaMixin, TitleMixin): + def get_queryset(self): + return self.model.authenticators.all() + + +class AuthenticatorsView(AuthenticatorsMixin, ListView): + template_name = 'authentic2/authenticators/authenticators.html' + model = BaseAuthenticator + title = _('Authenticators') + + +authenticators = AuthenticatorsView.as_view() + + +class AuthenticatorAddView(AuthenticatorsMixin, CreateView): + template_name = 'authentic2/authenticators/authenticator_add_form.html' + title = _('New authenticator') + form_class = forms.AuthenticatorAddForm + + +add = AuthenticatorAddView.as_view() + + +class AuthenticatorDetailView(AuthenticatorsMixin, DetailView): + template_name = 'authentic2/authenticators/authenticator_detail.html' + model = BaseAuthenticator + + @property + def title(self): + return str(self.object) + + +detail = AuthenticatorDetailView.as_view() + + +class AuthenticatorEditView(AuthenticatorsMixin, UpdateView): + template_name = 'authentic2/authenticators/authenticator_edit_form.html' + title = _('Edit authenticator') + model = BaseAuthenticator + + def get_form_class(self): + return self.object.manager_form_class + + +edit = AuthenticatorEditView.as_view() + + +class AuthenticatorDeleteView(AuthenticatorsMixin, DeleteView): + template_name = 'authentic2/authenticators/authenticator_delete_form.html' + title = _('Delete authenticator') + model = BaseAuthenticator + success_url = reverse_lazy('a2-manager-authenticators') + + +delete = AuthenticatorDeleteView.as_view() + + +class AuthenticatorToggleView(DetailView): + model = BaseAuthenticator + + def get(self, request, *args, **kwargs): + authenticator = self.get_object() + + if authenticator.enabled: + authenticator.enabled = False + authenticator.save() + message = _('Authenticator has been disabled.') + else: + authenticator.enabled = True + authenticator.save() + message = _('Authenticator has been enabled.') + + messages.info(self.request, message) + return HttpResponseRedirect(authenticator.get_absolute_url()) + + +toggle = AuthenticatorToggleView.as_view() diff --git a/src/authentic2/authenticators.py b/src/authentic2/authenticators.py index a1d584fff..2e7be7675 100644 --- a/src/authentic2/authenticators.py +++ b/src/authentic2/authenticators.py @@ -59,6 +59,9 @@ class BaseAuthenticator: logger.error(e) return False + def get_identifier(self): + return self.id + class LoginPasswordAuthenticator(BaseAuthenticator): id = 'password' diff --git a/src/authentic2/forms/mixins.py b/src/authentic2/forms/mixins.py index 83e316638..2336d9caa 100644 --- a/src/authentic2/forms/mixins.py +++ b/src/authentic2/forms/mixins.py @@ -14,9 +14,11 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import hashlib from collections import OrderedDict from django import forms +from django.utils.text import slugify from django.utils.translation import ugettext as _ @@ -76,3 +78,25 @@ class LockedFieldFormMixin: def is_field_locked(self, name): raise NotImplementedError + + +class SlugMixin(forms.ModelForm): + def save(self, commit=True): + instance = self.instance + if not instance.slug: + instance.slug = slugify(str(instance.name)).lstrip('_') + qs = self.get_existing_objects_queryset() + if instance.pk: + qs = qs.exclude(pk=instance.pk) + new_slug = instance.slug + i = 1 + while qs.filter(slug=new_slug).exists(): + new_slug = '%s-%d' % (instance.slug, i) + i += 1 + instance.slug = new_slug + if len(instance.slug) > 256: + instance.slug = instance.slug[:252] + hashlib.md5(instance.slug).hexdigest()[:4] + return super().save(commit=commit) + + def get_existing_objects_queryset(self): + return self.instance.__class__.objects.all() diff --git a/src/authentic2/manager/forms.py b/src/authentic2/manager/forms.py index 19781c1d5..8cfde240c 100644 --- a/src/authentic2/manager/forms.py +++ b/src/authentic2/manager/forms.py @@ -15,7 +15,6 @@ # along with this program. If not, see . import csv -import hashlib import json import logging import smtplib @@ -27,7 +26,6 @@ from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.urls import reverse -from django.utils.text import slugify from django.utils.translation import pgettext, ugettext from django.utils.translation import ugettext_lazy as _ from django_select2.forms import HeavySelect2Widget @@ -35,6 +33,7 @@ from django_select2.forms import HeavySelect2Widget from authentic2.a2_rbac.models import OrganizationalUnit, Permission, Role from authentic2.a2_rbac.utils import generate_slug, get_default_ou from authentic2.forms.fields import CheckPasswordField, NewPasswordField, ValidatedEmailField +from authentic2.forms.mixins import SlugMixin from authentic2.forms.profile import BaseUserForm from authentic2.models import PasswordReset from authentic2.passwords import generate_password @@ -65,25 +64,6 @@ class FormWithRequest(forms.Form): super().__init__(*args, **kwargs) -class SlugMixin(forms.ModelForm): - def save(self, commit=True): - instance = self.instance - if not instance.slug: - instance.slug = slugify(str(instance.name)).lstrip('_') - qs = instance.__class__.objects.all() - if instance.pk: - qs = qs.exclude(pk=instance.pk) - new_slug = instance.slug - i = 1 - while qs.filter(slug=new_slug).exists(): - new_slug = '%s-%d' % (instance.slug, i) - i += 1 - instance.slug = new_slug - if len(instance.slug) > 256: - instance.slug = instance.slug[:252] + hashlib.md5(instance.slug).hexdigest()[:4] - return super().save(commit=commit) - - class PrefixFormMixin: def __init__(self, *args, **kwargs): kwargs['prefix'] = self.__class__.prefix diff --git a/src/authentic2/manager/templates/authentic2/manager/homepage.html b/src/authentic2/manager/templates/authentic2/manager/homepage.html index 36f399085..c598d5138 100644 --- a/src/authentic2/manager/templates/authentic2/manager/homepage.html +++ b/src/authentic2/manager/templates/authentic2/manager/homepage.html @@ -20,6 +20,7 @@ {% if user.is_superuser %}
  • {% trans 'Technical information' %}
  • {% endif %} +
  • {% trans 'Authenticators' %}
  • {% endif %} diff --git a/src/authentic2/manager/urls.py b/src/authentic2/manager/urls.py index b8b2146b5..ead1f0be0 100644 --- a/src/authentic2/manager/urls.py +++ b/src/authentic2/manager/urls.py @@ -19,6 +19,7 @@ from django.contrib.auth.decorators import login_required from django.utils.functional import lazy from django.views.i18n import JavaScriptCatalog +from authentic2.apps.authenticators.manager_urls import urlpatterns as authenticator_urlpatterns from authentic2.utils import misc as utils_misc from ..decorators import required @@ -191,6 +192,8 @@ urlpatterns = required( ], ) +urlpatterns += authenticator_urlpatterns + urlpatterns += [ url( r'^jsi18n/$', diff --git a/src/authentic2/settings.py b/src/authentic2/settings.py index c4b1619e5..9a89438b2 100644 --- a/src/authentic2/settings.py +++ b/src/authentic2/settings.py @@ -144,6 +144,7 @@ INSTALLED_APPS = ( 'authentic2.attribute_aggregator', 'authentic2.disco_service', 'authentic2.manager', + 'authentic2.apps.authenticators', 'authentic2.apps.journal', 'authentic2.backends', 'authentic2', diff --git a/src/authentic2/utils/misc.py b/src/authentic2/utils/misc.py index b7e98f53a..39f8d54f2 100644 --- a/src/authentic2/utils/misc.py +++ b/src/authentic2/utils/misc.py @@ -163,6 +163,11 @@ def load_backend(path, kwargs): def get_backends(setting_name='IDP_BACKENDS'): '''Return the list of enabled cleaned backends.''' backends = [] + if setting_name == 'AUTH_FRONTENDS': + from authentic2.apps.authenticators.models import BaseAuthenticator + + backends = list(BaseAuthenticator.authenticators.filter(enabled=True)) + for backend_path in getattr(app_settings, setting_name): kwargs = {} if not isinstance(backend_path, str): @@ -214,7 +219,7 @@ def get_authenticator_method(authenticator, method, parameters): if hasattr(response, 'context_data') and response.context_data: extra_css_class = response.context_data.get('block-extra-css-class', '') return { - 'id': authenticator.id, + 'id': authenticator.get_identifier(), 'name': authenticator.name, 'content': content, 'response': response, diff --git a/src/authentic2/views.py b/src/authentic2/views.py index 93f2ca722..614498344 100644 --- a/src/authentic2/views.py +++ b/src/authentic2/views.py @@ -379,7 +379,7 @@ def login(request, template_name='authentic2/login.html', redirect_field_name=RE continue # Legacy API if not hasattr(authenticator, 'login'): - fid = authenticator.id + fid = authenticator.get_identifier() name = authenticator.name form_class = authenticator.form() submit_name = 'submit-%s' % fid @@ -513,7 +513,7 @@ class ProfileView(HomeURLMixin, cbv.TemplateNamesMixin, TemplateView): if request.method == "POST": for frontend in frontends: - if 'submit-%s' % frontend.id in request.POST: + if 'submit-%s' % frontend.get_identifier() in request.POST: form = frontend.form()(data=request.POST) if form.is_valid(): return frontend.post(request, form, None, '/profile') diff --git a/tests/test_manager_authenticators.py b/tests/test_manager_authenticators.py new file mode 100644 index 000000000..693643ebb --- /dev/null +++ b/tests/test_manager_authenticators.py @@ -0,0 +1,29 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2010-2022 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 . + +from .utils import login, logout + + +def test_authenticators_authorization(app, simple_user, superuser): + resp = login(app, simple_user) + app.get('/manage/authenticators/', status=403) + + logout(app) + resp = login(app, superuser, path='/manage/') + assert 'Authenticators' in resp.text + + resp = resp.click('Authenticators') + assert 'Authenticators' in resp.text -- 2.30.2