From 60601418af42216d01aa0aeb02e5d04114d56498 Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Thu, 25 Aug 2022 15:02:24 +0200 Subject: [PATCH 2/3] auth_oidc: add views to configure claims (#66419) --- .../journal_event_types.py | 91 +++++++++++++++++++ src/authentic2_auth_oidc/models.py | 1 + .../authenticator_detail.html | 20 ++++ src/authentic2_auth_oidc/urls.py | 25 +++++ src/authentic2_auth_oidc/views.py | 67 +++++++++++++- tests/test_manager_authenticators.py | 29 ++++++ tests/test_manager_journal.py | 30 ++++++ 7 files changed, 260 insertions(+), 3 deletions(-) create mode 100644 src/authentic2_auth_oidc/journal_event_types.py create mode 100644 src/authentic2_auth_oidc/templates/authentic2_auth_oidc/authenticator_detail.html diff --git a/src/authentic2_auth_oidc/journal_event_types.py b/src/authentic2_auth_oidc/journal_event_types.py new file mode 100644 index 00000000..b67284d2 --- /dev/null +++ b/src/authentic2_auth_oidc/journal_event_types.py @@ -0,0 +1,91 @@ +# 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.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 related object 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 related object 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 related object 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 diff --git a/src/authentic2_auth_oidc/models.py b/src/authentic2_auth_oidc/models.py index e1538be4..d5aeafa1 100644 --- a/src/authentic2_auth_oidc/models.py +++ b/src/authentic2_auth_oidc/models.py @@ -115,6 +115,7 @@ 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: diff --git a/src/authentic2_auth_oidc/templates/authentic2_auth_oidc/authenticator_detail.html b/src/authentic2_auth_oidc/templates/authentic2_auth_oidc/authenticator_detail.html new file mode 100644 index 00000000..5d826364 --- /dev/null +++ b/src/authentic2_auth_oidc/templates/authentic2_auth_oidc/authenticator_detail.html @@ -0,0 +1,20 @@ +{% extends 'authentic2/authenticators/authenticator_detail.html' %} +{% load i18n %} + +{% block extra-tab-buttons %} + +{% endblock %} + +{% block extra-tab-list %} + +{% endblock %} diff --git a/src/authentic2_auth_oidc/urls.py b/src/authentic2_auth_oidc/urls.py index c6a75bfc..87003b77 100644 --- a/src/authentic2_auth_oidc/urls.py +++ b/src/authentic2_auth_oidc/urls.py @@ -15,6 +15,10 @@ # along with this program. If not, see . 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 @@ -23,3 +27,24 @@ 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//claim/add/', + views.add_claim, + name='a2-manager-oidc-add-claim', + ), + path( + 'authenticators//claim//edit/', + views.edit_claim, + name='a2-manager-oidc-edit-claim', + ), + path( + 'authenticators//claim//delete/', + views.delete_claim, + name='a2-manager-oidc-delete-claim', + ), + ], +) diff --git a/src/authentic2_auth_oidc/views.py b/src/authentic2_auth_oidc/views.py index 1dc6d3aa..fa2890cb 100644 --- a/src/authentic2_auth_oidc/views.py +++ b/src/authentic2_auth_oidc/views.py @@ -24,15 +24,19 @@ 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 . import models +from .forms import OIDCClaimMappingForm +from .models import OIDCClaimMapping, OIDCProvider from .utils import get_provider, get_provider_by_issuer logger = logging.getLogger(__name__) @@ -110,7 +114,7 @@ def login_initiate(request, *args, **kwargs): issuer = request.GET['iss'] try: provider = get_provider_by_issuer(issuer) - except models.OIDCProvider.DoesNotExist: + except OIDCProvider.DoesNotExist: return HttpResponseBadRequest('unknown issuer %s' % issuer, content_type='text/plain') return oidc_login(request, pk=provider.pk, next_url=request.GET.get('target_link_uri')) @@ -148,7 +152,7 @@ class LoginCallback(View): try: provider = get_provider_by_issuer(issuer) - except models.OIDCProvider.DoesNotExist: + except OIDCProvider.DoesNotExist: messages.warning(request, _('Unknown OpenID connect issuer: "%s"') % issuer) logger.warning('auth_oidc: unknown issuer, %s', issuer) return self.continue_to_next_url(request) @@ -346,3 +350,60 @@ 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() diff --git a/tests/test_manager_authenticators.py b/tests/test_manager_authenticators.py index 0fdb6a67..43f8c8fa 100644 --- a/tests/test_manager_authenticators.py +++ b/tests/test_manager_authenticators.py @@ -193,6 +193,35 @@ def test_authenticators_oidc(app, superuser, ou1, ou2): assert_event('authenticator.deletion', user=superuser, session=app.session) +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.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 + + resp = resp.follow() + assert escape('email -> email, verified, required, idtoken') in resp.text + + resp = resp.click('email') + resp.form['attribute'].select(text='First Name (first_name)') + resp = resp.form.submit().follow() + assert escape('email -> first_name, verified, required, idtoken') in resp.text + assert_event('authenticator.oidc.claim.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) + + def test_authenticators_fc(app, superuser): resp = login(app, superuser, path='/manage/authenticators/') diff --git a/tests/test_manager_journal.py b/tests/test_manager_journal.py index 66990dc4..8de7f46f 100644 --- a/tests/test_manager_journal.py +++ b/tests/test_manager_journal.py @@ -28,6 +28,7 @@ from authentic2.apps.journal.models import Event, _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 RenameAttributeAction, SAMLAuthenticator from .utils import login, logout, text_content @@ -61,6 +62,8 @@ def events(db, freezer): authenticator = LoginPasswordAuthenticator.objects.create(slug='test') saml_authenticator = SAMLAuthenticator.objects.create(slug='saml') rename_attribute_action = RenameAttributeAction.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)) @@ -322,6 +325,14 @@ def events(db, freezer): session=session2, related_object=rename_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) @@ -359,6 +370,7 @@ def extract_journal(response): def test_global_journal(app, superuser, events): response = login(app, user=superuser, path="/manage/") rename_attribute_action = RenameAttributeAction.objects.get() + oidc_claim_mapping = OIDCClaimMapping.objects.get() # remove event about admin login Event.objects.filter(user=superuser).delete() @@ -735,6 +747,24 @@ def test_global_journal(app, superuser, events): 'type': 'authenticator.saml.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) -- 2.30.2