auth_oidc: use generic related object code (#53442)

This commit is contained in:
Valentin Deniaud 2022-09-20 14:17:19 +02:00
parent cf5132d72f
commit 5e156b6168
12 changed files with 98 additions and 253 deletions

View File

@ -16,6 +16,7 @@
from django import forms
from authentic2.forms.fields import RoleChoiceField
from authentic2.forms.widgets import DatalistTextInput, SelectAttributeWidget
from .models import OIDCClaimMapping, OIDCProvider
@ -80,3 +81,13 @@ class OIDCClaimMappingForm(forms.ModelForm):
widgets = {
'claim': OIDCClaimTextInput,
}
class OIDCRelatedObjectForm(forms.ModelForm):
class Meta:
exclude = ('authenticator',)
field_classes = {'role': RoleChoiceField}
widgets = {
'claim': OIDCClaimTextInput,
'attribute': SelectAttributeWidget,
}

View File

@ -1,91 +0,0 @@
# 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.utils.translation import gettext_lazy as _
from authentic2.apps.journal.models import EventTypeDefinition
from authentic2.apps.journal.utils import form_to_old_new
from .models import OIDCProvider
class OIDCClaimMappingEvents(EventTypeDefinition):
@classmethod
def record(cls, *, user, session, claim, data=None):
data = data or {}
data.update({'claim_id': claim.pk})
super().record(user=user, session=session, references=[claim.provider], data=data)
class OIDCClaimMappingCreation(OIDCClaimMappingEvents):
name = 'authenticator.oidc.claim.creation'
label = _('OIDC provider claim creation')
@classmethod
def get_message(cls, event, context):
(provider,) = event.get_typed_references(OIDCProvider)
claim_id = event.get_data('claim_id')
if context != provider:
return _('creation of claim ({claim_id}) in provider "{provider}"').format(
claim_id=claim_id, provider=provider
)
else:
return _('creation of claim (%s)') % claim_id
class OIDCClaimMappingEdit(OIDCClaimMappingEvents):
name = 'authenticator.oidc.claim.edit'
label = _('OIDC provider claim edit')
@classmethod
def record(cls, *, user, session, form):
super().record(
user=user,
session=session,
claim=form.instance,
data=form_to_old_new(form),
)
@classmethod
def get_message(cls, event, context):
(provider,) = event.get_typed_references(OIDCProvider)
claim_id = event.get_data('claim_id')
new = event.get_data('new') or {}
edited_attributes = ', '.join(new) or ''
if context != provider:
return _('edit claim ({claim_id}) in provider "{provider}" ({change})').format(
claim_id=claim_id,
provider=provider,
change=edited_attributes,
)
else:
return _('edit claim ({claim_id}) ({change})').format(claim_id=claim_id, change=edited_attributes)
class OIDCClaimMappingDeletion(OIDCClaimMappingEvents):
name = 'authenticator.oidc.claim.deletion'
label = _('OIDC provider claim deletion')
@classmethod
def get_message(cls, event, context):
(provider,) = event.get_typed_references(OIDCProvider)
claim_id = event.get_data('claim_id')
if context != provider:
return _('deletion of claim ({claim_id}) in provider "{provider}"').format(
claim_id=claim_id, provider=provider
)
else:
return _('deletion of claim %s') % claim_id

View File

@ -52,6 +52,11 @@ class Migration(migrations.Migration):
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
],
options={
'verbose_name': 'Claim',
'verbose_name_plural': 'Claims',
'default_related_name': 'claim_mappings',
},
),
migrations.CreateModel(
name='OIDCProvider',

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.26 on 2022-09-20 14:14
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('authentic2_auth_oidc', '0013_auto_20220726_1714'),
]
operations = [
migrations.RenameField(
model_name='oidcclaimmapping',
old_name='provider',
new_name='authenticator',
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 2.2.26 on 2022-09-22 09:52
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('authentic2_auth_oidc', '0014_auto_20220920_1614'),
]
operations = [
migrations.AlterField(
model_name='oidcclaimmapping',
name='authenticator',
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='claim_mappings',
to='authenticators.BaseAuthenticator',
),
),
]

View File

@ -26,7 +26,7 @@ from django.utils.translation import gettext_lazy as _
from jwcrypto.jwk import InvalidJWKValue, JWKSet
from authentic2.a2_rbac.utils import get_default_ou
from authentic2.apps.authenticators.models import BaseAuthenticator
from authentic2.apps.authenticators.models import AuthenticatorRelatedObjectBase, BaseAuthenticator
from authentic2.utils.misc import make_url, redirect_to_login
from authentic2.utils.template import validate_template
@ -115,7 +115,6 @@ class OIDCProvider(BaseAuthenticator):
type = 'oidc'
how = ['oidc']
manager_view_template_name = 'authentic2_auth_oidc/authenticator_detail.html'
description_fields = ['show_condition', 'issuer', 'scopes', 'strategy', 'created', 'modified']
class Meta:
@ -127,6 +126,18 @@ class OIDCProvider(BaseAuthenticator):
return OIDCProviderEditForm
@property
def related_object_form_class(self):
from .forms import OIDCRelatedObjectForm
return OIDCRelatedObjectForm
@property
def related_models(self):
return {
OIDCClaimMapping: self.claim_mappings.all(),
}
@property
def jwkset(self):
if self.jwkset_json:
@ -215,7 +226,7 @@ class OIDCProvider(BaseAuthenticator):
return render(request, template_names, context)
class OIDCClaimMapping(models.Model):
class OIDCClaimMapping(AuthenticatorRelatedObjectBase):
NOT_VERIFIED = 0
VERIFIED_CLAIM = 1
ALWAYS_VERIFIED = 2
@ -225,9 +236,6 @@ class OIDCClaimMapping(models.Model):
(ALWAYS_VERIFIED, _('always verified')),
]
provider = models.ForeignKey(
to='OIDCProvider', verbose_name=_('provider'), related_name='claim_mappings', on_delete=models.CASCADE
)
claim = models.CharField(max_length=128, verbose_name=_('claim'), validators=[validate_template])
attribute = models.CharField(max_length=64, verbose_name=_('attribute'))
verified = models.PositiveIntegerField(
@ -240,6 +248,11 @@ class OIDCClaimMapping(models.Model):
objects = managers.OIDCClaimMappingManager()
class Meta:
default_related_name = 'claim_mappings'
verbose_name = _('Claim')
verbose_name_plural = _('Claims')
def natural_key(self):
return (self.claim, self.attribute, self.verified, self.required)
@ -262,7 +275,7 @@ class OIDCClaimMapping(models.Model):
return '<OIDCClaimMapping %r:%r on provider %r verified:%s required:%s >' % (
self.claim,
self.attribute,
self.provider and self.provider.issuer,
self.authenticator,
self.verified,
self.required,
)

View File

@ -1,20 +0,0 @@
{% extends 'authentic2/authenticators/authenticator_detail.html' %}
{% load i18n %}
{% block extra-tab-buttons %}
<button aria-controls="panel-claims" aria-selected="false" id="tab-claims" role="tab" tabindex="-1">{% trans "Claims" %}</button>
{% endblock %}
{% block extra-tab-list %}
<div aria-labelledby="tab-claims" hidden="" id="panel-claims" role="tabpanel" tabindex="0">
<ul class="objects-list single-links">
{% for claim in object.claim_mappings.all %}
<li>
<a rel="popup" href="{% url 'a2-manager-oidc-edit-claim' authenticator_pk=object.pk pk=claim.pk %}">{{ claim }}</a>
<a rel="popup" class="delete" href="{% url 'a2-manager-oidc-delete-claim' authenticator_pk=object.pk pk=claim.pk %}">{% trans "Remove" %}</a>
</li>
{% endfor %}
<li><a class="add" rel="popup" href="{% url 'a2-manager-oidc-add-claim' authenticator_pk=object.pk %}">{% trans 'Add' %}</a></li>
</ul>
</div>
{% endblock %}

View File

@ -15,10 +15,6 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.conf.urls import url
from django.urls import path
from authentic2.apps.authenticators.manager_urls import superuser_login_required
from authentic2.decorators import required
from . import views
@ -27,24 +23,3 @@ urlpatterns = [
url(r'^accounts/oidc/login/$', views.login_initiate, name='oidc-login-initiate'),
url(r'^accounts/oidc/callback/$', views.login_callback, name='oidc-login-callback'),
]
urlpatterns += required(
superuser_login_required,
[
path(
'authenticators/<int:authenticator_pk>/claim/add/',
views.add_claim,
name='a2-manager-oidc-add-claim',
),
path(
'authenticators/<int:authenticator_pk>/claim/<int:pk>/edit/',
views.edit_claim,
name='a2-manager-oidc-edit-claim',
),
path(
'authenticators/<int:authenticator_pk>/claim/<int:pk>/delete/',
views.delete_claim,
name='a2-manager-oidc-delete-claim',
),
],
)

View File

@ -24,19 +24,15 @@ from django.conf import settings
from django.contrib import messages
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.http import HttpResponseBadRequest
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.utils.translation import get_language
from django.utils.translation import ugettext as _
from django.views.generic import CreateView, DeleteView, UpdateView
from django.views.generic.base import View
from authentic2.manager.views import MediaMixin, TitleMixin
from authentic2.utils import crypto
from authentic2.utils.misc import authenticate, good_next_url, login, redirect
from .forms import OIDCClaimMappingForm
from .models import OIDCClaimMapping, OIDCProvider
from .models import OIDCProvider
from .utils import get_provider, get_provider_by_issuer
logger = logging.getLogger(__name__)
@ -350,60 +346,3 @@ class LoginCallback(View):
login_callback = LoginCallback.as_view()
class OIDCProviderMixin(MediaMixin, TitleMixin):
model = OIDCClaimMapping
def dispatch(self, request, *args, **kwargs):
self.provider = get_object_or_404(OIDCProvider, pk=kwargs.get('authenticator_pk'))
return super().dispatch(request, *args, **kwargs)
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
if not kwargs.get('instance'):
kwargs['instance'] = self.model()
kwargs['instance'].provider = self.provider
return kwargs
def get_success_url(self):
return reverse('a2-manager-authenticator-detail', kwargs={'pk': self.provider.pk}) + '#open:claims'
class OIDCClaimMappingAddView(OIDCProviderMixin, CreateView):
template_name = 'authentic2/manager/form.html'
title = _('New claim')
form_class = OIDCClaimMappingForm
def form_valid(self, form):
resp = super().form_valid(form)
self.request.journal.record('authenticator.oidc.claim.creation', claim=form.instance)
return resp
add_claim = OIDCClaimMappingAddView.as_view()
class OIDCClaimMappingEditView(OIDCProviderMixin, UpdateView):
template_name = 'authentic2/manager/form.html'
title = _('Edit claim')
form_class = OIDCClaimMappingForm
def form_valid(self, form):
resp = super().form_valid(form)
self.request.journal.record('authenticator.oidc.claim.edit', form=form)
return resp
edit_claim = OIDCClaimMappingEditView.as_view()
class OIDCClaimMappingDeleteView(OIDCProviderMixin, DeleteView):
template_name = 'authentic2/authenticators/authenticator_delete_form.html'
def delete(self, *args, **kwargs):
self.request.journal.record('authenticator.oidc.claim.deletion', claim=self.get_object())
return super().delete(*args, **kwargs)
delete_claim = OIDCClaimMappingDeleteView.as_view()

View File

@ -184,24 +184,26 @@ def make_oidc_provider(
button_label=name,
)
provider.full_clean()
OIDCClaimMapping.objects.create(provider=provider, claim='sub', attribute='username', idtoken_claim=True)
OIDCClaimMapping.objects.create(provider=provider, claim='email', attribute='email')
OIDCClaimMapping.objects.create(provider=provider, claim='email', required=True, attribute='email')
OIDCClaimMapping.objects.create(
provider=provider,
authenticator=provider, claim='sub', attribute='username', idtoken_claim=True
)
OIDCClaimMapping.objects.create(authenticator=provider, claim='email', attribute='email')
OIDCClaimMapping.objects.create(authenticator=provider, claim='email', required=True, attribute='email')
OIDCClaimMapping.objects.create(
authenticator=provider,
claim='given_name',
required=True,
verified=OIDCClaimMapping.ALWAYS_VERIFIED,
attribute='first_name',
)
OIDCClaimMapping.objects.create(
provider=provider,
authenticator=provider,
claim='family_name',
required=True,
verified=OIDCClaimMapping.VERIFIED_CLAIM,
attribute='last_name',
)
OIDCClaimMapping.objects.create(provider=provider, claim='ou', attribute='ou__slug')
OIDCClaimMapping.objects.create(authenticator=provider, claim='ou', attribute='ou__slug')
return provider
@ -672,7 +674,7 @@ def test_strategy_find_uuid(app, caplog, code, oidc_provider, oidc_provider_jwks
def test_strategy_find_email(app, caplog, code, oidc_provider, oidc_provider_jwkset, simple_user):
OIDCClaimMapping.objects.all().delete()
OIDCClaimMapping.objects.create(
provider=oidc_provider,
authenticator=oidc_provider,
claim='email',
attribute='email',
idtoken_claim=False, # served by user_info endpoint
@ -729,7 +731,7 @@ def test_strategy_find_email_normalized_unicode_collision_prevention(
):
OIDCClaimMapping.objects.all().delete()
OIDCClaimMapping.objects.create(
provider=oidc_provider,
authenticator=oidc_provider,
claim='email',
attribute='email',
idtoken_claim=False, # served by user_info endpoint
@ -908,33 +910,33 @@ def test_templated_claim_mapping(app, caplog, code, oidc_provider, oidc_provider
OIDCClaimMapping.objects.all().delete()
OIDCClaimMapping.objects.create(
provider=oidc_provider,
authenticator=oidc_provider,
attribute='username',
idtoken_claim=False,
claim='{{ given_name }} "{{ nickname }}" {{ family_name }}',
)
OIDCClaimMapping.objects.create(
provider=oidc_provider,
authenticator=oidc_provider,
attribute='pro_phone',
idtoken_claim=False,
claim='(prefix +33) {{ phone_number }}',
)
OIDCClaimMapping.objects.create(
provider=oidc_provider,
authenticator=oidc_provider,
attribute='email',
idtoken_claim=False,
claim='{{ given_name }}@foo.bar',
)
# last one, with an idtoken claim
OIDCClaimMapping.objects.create(
provider=oidc_provider,
authenticator=oidc_provider,
attribute='last_name',
idtoken_claim=True,
claim='{{ name|upper }}',
)
# typo in template string
OIDCClaimMapping.objects.create(
provider=oidc_provider,
authenticator=oidc_provider,
attribute='first_name',
idtoken_claim=True,
claim='{{ given_name',

View File

@ -198,15 +198,15 @@ def test_authenticators_oidc_claims(app, superuser):
authenticator = OIDCProvider.objects.create(slug='idp1')
resp = login(app, superuser, path=authenticator.get_absolute_url())
resp = resp.click('Add')
resp = resp.click('Add', href='claim')
resp.form['claim'] = 'email'
resp.form['attribute'].select(text='Email address (email)')
resp.form['verified'].select(text='verified claim')
resp.form['required'] = True
resp.form['idtoken_claim'] = True
resp = resp.form.submit()
assert_event('authenticator.oidc.claim.creation', user=superuser, session=app.session)
assert '#open:claims' in resp.location
assert_event('authenticator.related_object.creation', user=superuser, session=app.session)
assert '#open:oidcclaimmapping' in resp.location
resp = resp.follow()
assert 'email → Email address (email), verified, required, idtoken' in resp.text
@ -215,12 +215,12 @@ def test_authenticators_oidc_claims(app, superuser):
resp.form['attribute'].select(text='First name (first_name)')
resp = resp.form.submit().follow()
assert 'email → First name (first_name), verified, required, idtoken' in resp.text
assert_event('authenticator.oidc.claim.edit', user=superuser, session=app.session)
assert_event('authenticator.related_object.edit', user=superuser, session=app.session)
resp = resp.click('Remove')
resp = resp.form.submit().follow()
assert 'email' not in resp.text
assert_event('authenticator.oidc.claim.deletion', user=superuser, session=app.session)
assert_event('authenticator.related_object.deletion', user=superuser, session=app.session)
def test_authenticators_fc(app, superuser):

View File

@ -28,7 +28,6 @@ from authentic2.apps.journal.models import Event, EventType, _registry
from authentic2.custom_user.models import Profile, ProfileType, User
from authentic2.journal import journal
from authentic2.models import Service
from authentic2_auth_oidc.models import OIDCClaimMapping, OIDCProvider
from authentic2_auth_saml.models import SAMLAuthenticator, SetAttributeAction
from .utils import login, logout, text_content
@ -62,8 +61,6 @@ def events(db, freezer):
authenticator = LoginPasswordAuthenticator.objects.create(slug='test')
saml_authenticator = SAMLAuthenticator.objects.create(slug='saml')
set_attribute_action = SetAttributeAction.objects.create(authenticator=saml_authenticator)
oidc_provider = OIDCProvider.objects.create(slug='oidc')
oidc_claim_mapping = OIDCClaimMapping.objects.create(provider=oidc_provider)
class EventFactory:
date = make_aware(datetime.datetime(2020, 1, 1))
@ -325,14 +322,6 @@ def events(db, freezer):
session=session2,
related_object=set_attribute_action,
)
make('authenticator.oidc.claim.creation', user=agent, session=session2, claim=oidc_claim_mapping)
claim_edit_form = mock.Mock(spec=['instance', 'initial', 'changed_data', 'cleaned_data'])
claim_edit_form.instance = oidc_claim_mapping
claim_edit_form.initial = {'claim': 'email'}
claim_edit_form.changed_data = ['claim']
claim_edit_form.cleaned_data = {'claim': 'first_name'}
make('authenticator.oidc.claim.edit', user=agent, session=session2, form=claim_edit_form)
make('authenticator.oidc.claim.deletion', user=agent, session=session2, claim=oidc_claim_mapping)
# verify we created at least one event for each type
assert set(Event.objects.values_list("type__name", flat=True)) == set(_registry)
@ -370,7 +359,6 @@ def extract_journal(response):
def test_global_journal(app, superuser, events):
response = login(app, user=superuser, path="/manage/")
set_attribute_action = SetAttributeAction.objects.get()
oidc_claim_mapping = OIDCClaimMapping.objects.get()
# remove event about admin login
Event.objects.filter(user=superuser).delete()
@ -747,24 +735,6 @@ def test_global_journal(app, superuser, events):
'type': 'authenticator.related_object.deletion',
'user': 'agent',
},
{
'message': 'creation of claim (%s) in provider "OpenIDConnect"' % oidc_claim_mapping.pk,
'timestamp': 'Jan. 3, 2020, 11 a.m.',
'type': 'authenticator.oidc.claim.creation',
'user': 'agent',
},
{
'message': 'edit claim (%s) in provider "OpenIDConnect" (claim)' % oidc_claim_mapping.pk,
'timestamp': 'Jan. 3, 2020, noon',
'type': 'authenticator.oidc.claim.edit',
'user': 'agent',
},
{
'message': 'deletion of claim (%s) in provider "OpenIDConnect"' % oidc_claim_mapping.pk,
'timestamp': 'Jan. 3, 2020, 1 p.m.',
'type': 'authenticator.oidc.claim.deletion',
'user': 'agent',
},
]
agent_page = response.click('agent', index=1)