authenticators: add new app (#53902)

This commit is contained in:
Valentin Deniaud 2022-04-13 13:58:05 +02:00
parent 03082ffc11
commit 8532ac64af
23 changed files with 596 additions and 24 deletions

View File

@ -0,0 +1,37 @@
# 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 <http://www.gnu.org/licenses/>.
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()

View File

@ -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 <http://www.gnu.org/licenses/>.
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/<int:pk>/detail/',
views.detail,
name='a2-manager-authenticator-detail',
),
path(
'authenticators/<int:pk>/edit/',
views.edit,
name='a2-manager-authenticator-edit',
),
path(
'authenticators/<int:pk>/delete/',
views.delete,
name='a2-manager-authenticator-delete',
),
path(
'authenticators/<int:pk>/toggle/',
views.toggle,
name='a2-manager-authenticator-toggle',
),
],
)

View File

@ -0,0 +1,60 @@
# Generated by Django 2.2.26 on 2022-05-17 14:24
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, editable=False, max_length=255, unique=True)),
('name', models.CharField(max_length=128, verbose_name='Name')),
('slug', models.SlugField(unique=True)),
('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(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.RBAC_OU_MODEL,
verbose_name='organizational unit',
),
),
],
options={
'ordering': ('-enabled', 'name', 'slug', 'ou'),
},
),
]

View File

@ -0,0 +1,108 @@
# 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 <http://www.gnu.org/licenses/>.
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(unique=True)
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')
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

View File

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

View File

@ -0,0 +1,18 @@
{% extends "authentic2/authenticators/authenticator_common.html" %}
{% load gadjo i18n %}
{% block breadcrumb %}
{{ block.super }}
<a href="#"></a>
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form|with_template }}
<div class="buttons">
<button>{% trans "Add" %}</button>
<a class="cancel" href="{% url 'a2-manager-authenticators' %}">{% trans 'Cancel' %}</a>
</div>
</form>
{% endblock %}

View File

@ -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 }}
<a href="{% url 'a2-manager-authenticators' %}">{% trans "Authenticators" %}</a>
{% endblock %}

View File

@ -0,0 +1,19 @@
{% extends "authentic2/authenticators/authenticator_common.html" %}
{% load i18n gadjo %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'a2-manager-authenticators' %}">{% trans "Authenticators" %}</a>
<a href="#"></a>
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<p>{% blocktrans %}Do you want to delete "{{ object }}" ?{% endblocktrans %}</p>
<div class="buttons">
<button class="delete-button">{% trans "Delete" %}</button>
<a class="cancel" href="{% url 'a2-manager-authenticator-detail' pk=object.pk %}">{% trans 'Cancel' %}</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,30 @@
{% extends "authentic2/authenticators/authenticator_common.html" %}
{% load i18n gadjo %}
{% block appbar %}
{{ block.super }}
<span class="actions">
<a class="extra-actions-menu-opener"></a>
<a href="{% url 'a2-manager-authenticator-toggle' pk=object.pk %}">{{ object.enabled|yesno:_("Disable,Enable") }}</a>
<a href="{% url 'a2-manager-authenticator-edit' pk=object.pk %}">{% trans "Edit" %}</a>
<ul class="extra-actions-menu">
<li><a rel="popup" href="{% url 'a2-manager-authenticator-delete' pk=object.pk %}">{% trans "Delete" %}</a></li>
</ul>
</span>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="#"></a>
{% endblock %}
{% block content %}
<div class='placeholder'>
{% for line in object.get_full_description %}
<p>{{ line }}</p>
{% empty %}
<p>{% trans 'Click "Edit" to change configuration.' %}</p>
{% endfor %}
</div>
{% endblock %}

View File

@ -0,0 +1,19 @@
{% extends "authentic2/authenticators/authenticator_common.html" %}
{% load i18n gadjo %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'a2-manager-authenticator-detail' pk=object.pk %}">{{ object }}</a>
<a href="#"></a>
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form|with_template }}
<div class="buttons">
<button>{% trans "Save" %}</button>
<a class="cancel" href="{% url 'a2-manager-authenticator-detail' pk=object.pk %}">{% trans 'Cancel' %}</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,24 @@
{% extends "authentic2/authenticators/authenticator_common.html" %}
{% load i18n gadjo %}
{% block appbar %}
{{ block.super }}
<span class="actions">
<a href="{% url 'a2-manager-authenticator-add' %}" rel="popup">{% trans "Add new authenticator" %}</a>
</span>
{% endblock %}
{% block main %}
{% for authenticator in object_list %}
<div class="section {% if not authenticator.enabled %}disabled{% endif %}">
<h3>{{ authenticator }}
<a class="button" href="{% url 'a2-manager-authenticator-detail' pk=authenticator.pk %}">{% trans "Configure" %}</a>
</h3>
{% if authenticator.enabled and authenticator.get_short_description %}
<div>
<p>{{ authenticator.get_short_description }}</p>
</div>
{% endif %}
</div>
{% endfor %}
{% endblock %}

View File

@ -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 <http://www.gnu.org/licenses/>.
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()

View File

@ -59,6 +59,9 @@ class BaseAuthenticator:
logger.error(e)
return False
def get_identifier(self):
return self.id
class LoginPasswordAuthenticator(BaseAuthenticator):
id = 'password'

View File

@ -14,9 +14,11 @@
# 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 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,22 @@ 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 = 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)

View File

@ -15,7 +15,6 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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

View File

@ -20,6 +20,7 @@
{% if user.is_superuser %}
<li><a href="{% url 'a2-manager-tech-info' %}">{% trans 'Technical information' %}</a></li>
{% endif %}
<li><a href="{% url 'a2-manager-authenticators' %}">{% trans 'Authenticators' %}</a></li>
</ul>
</span>
{% endif %}

View File

@ -20,6 +20,7 @@ from django.conf.urls import url
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
@ -200,6 +201,8 @@ urlpatterns = required(
],
)
urlpatterns += authenticator_urlpatterns
urlpatterns += [
url(
r'^jsi18n/$',

View File

@ -143,6 +143,7 @@ INSTALLED_APPS = (
'authentic2.attribute_aggregator',
'authentic2.disco_service',
'authentic2.manager',
'authentic2.apps.authenticators',
'authentic2.apps.journal',
'authentic2.backends',
'authentic2',

View File

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

View File

@ -380,7 +380,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
@ -514,7 +514,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')

View File

@ -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 <http://www.gnu.org/licenses/>.
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