Projet

Général

Profil

0002-auth_oidc-add-views-to-configure-claims-66419.patch

Valentin Deniaud, 25 août 2022 17:08

Télécharger (16,5 ko)

Voir les différences:

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
src/authentic2_auth_oidc/journal_event_types.py
1
# authentic2 - versatile identity manager
2
# Copyright (C) 2010-2022 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17
from django.utils.translation import gettext_lazy as _
18

  
19
from authentic2.apps.journal.models import EventTypeDefinition
20
from authentic2.apps.journal.utils import form_to_old_new
21

  
22
from .models import OIDCProvider
23

  
24

  
25
class OIDCClaimMappingEvents(EventTypeDefinition):
26
    @classmethod
27
    def record(cls, *, user, session, claim, data=None):
28
        data = data or {}
29
        data.update({'claim_id': claim.pk})
30
        super().record(user=user, session=session, references=[claim.provider], data=data)
31

  
32

  
33
class OIDCClaimMappingCreation(OIDCClaimMappingEvents):
34
    name = 'authenticator.oidc.claim.creation'
35
    label = _('OIDC provider related object creation')
36

  
37
    @classmethod
38
    def get_message(cls, event, context):
39
        (provider,) = event.get_typed_references(OIDCProvider)
40
        claim_id = event.get_data('claim_id')
41
        if context != provider:
42
            return _('creation of claim ({claim_id}) in provider "{provider}"').format(
43
                claim_id=claim_id, provider=provider
44
            )
45
        else:
46
            return _('creation of claim (%s)') % claim_id
47

  
48

  
49
class OIDCClaimMappingEdit(OIDCClaimMappingEvents):
50
    name = 'authenticator.oidc.claim.edit'
51
    label = _('OIDC provider related object edit')
52

  
53
    @classmethod
54
    def record(cls, *, user, session, form):
55
        super().record(
56
            user=user,
57
            session=session,
58
            claim=form.instance,
59
            data=form_to_old_new(form),
60
        )
61

  
62
    @classmethod
63
    def get_message(cls, event, context):
64
        (provider,) = event.get_typed_references(OIDCProvider)
65
        claim_id = event.get_data('claim_id')
66
        new = event.get_data('new') or {}
67
        edited_attributes = ', '.join(new) or ''
68
        if context != provider:
69
            return _('edit claim ({claim_id}) in provider "{provider}" ({change})').format(
70
                claim_id=claim_id,
71
                provider=provider,
72
                change=edited_attributes,
73
            )
74
        else:
75
            return _('edit claim ({claim_id}) ({change})').format(claim_id=claim_id, change=edited_attributes)
76

  
77

  
78
class OIDCClaimMappingDeletion(OIDCClaimMappingEvents):
79
    name = 'authenticator.oidc.claim.deletion'
80
    label = _('OIDC provider related object deletion')
81

  
82
    @classmethod
83
    def get_message(cls, event, context):
84
        (provider,) = event.get_typed_references(OIDCProvider)
85
        claim_id = event.get_data('claim_id')
86
        if context != provider:
87
            return _('deletion of claim ({claim_id}) in provider "{provider}"').format(
88
                claim_id=claim_id, provider=provider
89
            )
90
        else:
91
            return _('deletion of claim %s') % claim_id
src/authentic2_auth_oidc/models.py
115 115

  
116 116
    type = 'oidc'
117 117
    how = ['oidc']
118
    manager_view_template_name = 'authentic2_auth_oidc/authenticator_detail.html'
118 119
    description_fields = ['show_condition', 'issuer', 'scopes', 'strategy', 'created', 'modified']
119 120

  
120 121
    class Meta:
src/authentic2_auth_oidc/templates/authentic2_auth_oidc/authenticator_detail.html
1
{% extends 'authentic2/authenticators/authenticator_detail.html' %}
2
{% load i18n %}
3

  
4
{% block extra-tab-buttons %}
5
  <button aria-controls="panel-claims" aria-selected="false" id="tab-claims" role="tab" tabindex="-1">{% trans "Claims" %}</button>
6
{% endblock %}
7

  
8
{% block extra-tab-list %}
9
  <div aria-labelledby="tab-claims" hidden="" id="panel-claims" role="tabpanel" tabindex="0">
10
    <ul class="objects-list single-links">
11
      {% for claim in object.claim_mappings.all %}
12
        <li>
13
          <a rel="popup" href="{% url 'a2-manager-oidc-edit-claim' authenticator_pk=object.pk pk=claim.pk %}">{{ claim }}</a>
14
          <a rel="popup" class="delete" href="{% url 'a2-manager-oidc-delete-claim' authenticator_pk=object.pk pk=claim.pk %}">{% trans "Remove" %}</a>
15
        </li>
16
      {% endfor %}
17
      <li><a class="add" rel="popup" href="{% url 'a2-manager-oidc-add-claim' authenticator_pk=object.pk %}">{% trans 'Add' %}</a></li>
18
    </ul>
19
  </div>
20
{% endblock %}
src/authentic2_auth_oidc/urls.py
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17 17
from django.conf.urls import url
18
from django.urls import path
19

  
20
from authentic2.apps.authenticators.manager_urls import superuser_login_required
21
from authentic2.decorators import required
18 22

  
19 23
from . import views
20 24

  
......
23 27
    url(r'^accounts/oidc/login/$', views.login_initiate, name='oidc-login-initiate'),
24 28
    url(r'^accounts/oidc/callback/$', views.login_callback, name='oidc-login-callback'),
25 29
]
30

  
31
urlpatterns += required(
32
    superuser_login_required,
33
    [
34
        path(
35
            'authenticators/<int:authenticator_pk>/claim/add/',
36
            views.add_claim,
37
            name='a2-manager-oidc-add-claim',
38
        ),
39
        path(
40
            'authenticators/<int:authenticator_pk>/claim/<int:pk>/edit/',
41
            views.edit_claim,
42
            name='a2-manager-oidc-edit-claim',
43
        ),
44
        path(
45
            'authenticators/<int:authenticator_pk>/claim/<int:pk>/delete/',
46
            views.delete_claim,
47
            name='a2-manager-oidc-delete-claim',
48
        ),
49
    ],
50
)
src/authentic2_auth_oidc/views.py
24 24
from django.contrib import messages
25 25
from django.contrib.auth import REDIRECT_FIELD_NAME
26 26
from django.http import HttpResponseBadRequest
27
from django.shortcuts import get_object_or_404
27 28
from django.urls import reverse
28 29
from django.utils.translation import get_language
29 30
from django.utils.translation import ugettext as _
31
from django.views.generic import CreateView, DeleteView, UpdateView
30 32
from django.views.generic.base import View
31 33

  
34
from authentic2.manager.views import MediaMixin, TitleMixin
32 35
from authentic2.utils import crypto
33 36
from authentic2.utils.misc import authenticate, good_next_url, login, redirect
34 37

  
35
from . import models
38
from .forms import OIDCClaimMappingForm
39
from .models import OIDCClaimMapping, OIDCProvider
36 40
from .utils import get_provider, get_provider_by_issuer
37 41

  
38 42
logger = logging.getLogger(__name__)
......
110 114
    issuer = request.GET['iss']
111 115
    try:
112 116
        provider = get_provider_by_issuer(issuer)
113
    except models.OIDCProvider.DoesNotExist:
117
    except OIDCProvider.DoesNotExist:
114 118
        return HttpResponseBadRequest('unknown issuer %s' % issuer, content_type='text/plain')
115 119
    return oidc_login(request, pk=provider.pk, next_url=request.GET.get('target_link_uri'))
116 120

  
......
148 152

  
149 153
        try:
150 154
            provider = get_provider_by_issuer(issuer)
151
        except models.OIDCProvider.DoesNotExist:
155
        except OIDCProvider.DoesNotExist:
152 156
            messages.warning(request, _('Unknown OpenID connect issuer: "%s"') % issuer)
153 157
            logger.warning('auth_oidc: unknown issuer, %s', issuer)
154 158
            return self.continue_to_next_url(request)
......
346 350

  
347 351

  
348 352
login_callback = LoginCallback.as_view()
353

  
354

  
355
class OIDCProviderMixin(MediaMixin, TitleMixin):
356
    model = OIDCClaimMapping
357

  
358
    def dispatch(self, request, *args, **kwargs):
359
        self.provider = get_object_or_404(OIDCProvider, pk=kwargs.get('authenticator_pk'))
360
        return super().dispatch(request, *args, **kwargs)
361

  
362
    def get_form_kwargs(self):
363
        kwargs = super().get_form_kwargs()
364
        if not kwargs.get('instance'):
365
            kwargs['instance'] = self.model()
366
        kwargs['instance'].provider = self.provider
367
        return kwargs
368

  
369
    def get_success_url(self):
370
        return reverse('a2-manager-authenticator-detail', kwargs={'pk': self.provider.pk}) + '#open:claims'
371

  
372

  
373
class OIDCClaimMappingAddView(OIDCProviderMixin, CreateView):
374
    template_name = 'authentic2/manager/form.html'
375
    title = _('New claim')
376
    form_class = OIDCClaimMappingForm
377

  
378
    def form_valid(self, form):
379
        resp = super().form_valid(form)
380
        self.request.journal.record('authenticator.oidc.claim.creation', claim=form.instance)
381
        return resp
382

  
383

  
384
add_claim = OIDCClaimMappingAddView.as_view()
385

  
386

  
387
class OIDCClaimMappingEditView(OIDCProviderMixin, UpdateView):
388
    template_name = 'authentic2/manager/form.html'
389
    title = _('Edit claim')
390
    form_class = OIDCClaimMappingForm
391

  
392
    def form_valid(self, form):
393
        resp = super().form_valid(form)
394
        self.request.journal.record('authenticator.oidc.claim.edit', form=form)
395
        return resp
396

  
397

  
398
edit_claim = OIDCClaimMappingEditView.as_view()
399

  
400

  
401
class OIDCClaimMappingDeleteView(OIDCProviderMixin, DeleteView):
402
    template_name = 'authentic2/authenticators/authenticator_delete_form.html'
403

  
404
    def delete(self, *args, **kwargs):
405
        self.request.journal.record('authenticator.oidc.claim.deletion', claim=self.get_object())
406
        return super().delete(*args, **kwargs)
407

  
408

  
409
delete_claim = OIDCClaimMappingDeleteView.as_view()
tests/test_manager_authenticators.py
193 193
    assert_event('authenticator.deletion', user=superuser, session=app.session)
194 194

  
195 195

  
196
def test_authenticators_oidc_claims(app, superuser):
197
    authenticator = OIDCProvider.objects.create(slug='idp1')
198
    resp = login(app, superuser, path=authenticator.get_absolute_url())
199

  
200
    resp = resp.click('Add')
201
    resp.form['claim'] = 'email'
202
    resp.form['attribute'].select(text='Email Address (email)')
203
    resp.form['verified'].select(text='verified claim')
204
    resp.form['required'] = True
205
    resp.form['idtoken_claim'] = True
206
    resp = resp.form.submit()
207
    assert_event('authenticator.oidc.claim.creation', user=superuser, session=app.session)
208
    assert '#open:claims' in resp.location
209

  
210
    resp = resp.follow()
211
    assert escape('email -> email, verified, required, idtoken') in resp.text
212

  
213
    resp = resp.click('email')
214
    resp.form['attribute'].select(text='First Name (first_name)')
215
    resp = resp.form.submit().follow()
216
    assert escape('email -> first_name, verified, required, idtoken') in resp.text
217
    assert_event('authenticator.oidc.claim.edit', user=superuser, session=app.session)
218

  
219
    resp = resp.click('Remove')
220
    resp = resp.form.submit().follow()
221
    assert 'email' not in resp.text
222
    assert_event('authenticator.oidc.claim.deletion', user=superuser, session=app.session)
223

  
224

  
196 225
def test_authenticators_fc(app, superuser):
197 226
    resp = login(app, superuser, path='/manage/authenticators/')
198 227

  
tests/test_manager_journal.py
28 28
from authentic2.custom_user.models import Profile, ProfileType, User
29 29
from authentic2.journal import journal
30 30
from authentic2.models import Service
31
from authentic2_auth_oidc.models import OIDCClaimMapping, OIDCProvider
31 32
from authentic2_auth_saml.models import RenameAttributeAction, SAMLAuthenticator
32 33

  
33 34
from .utils import login, logout, text_content
......
61 62
    authenticator = LoginPasswordAuthenticator.objects.create(slug='test')
62 63
    saml_authenticator = SAMLAuthenticator.objects.create(slug='saml')
63 64
    rename_attribute_action = RenameAttributeAction.objects.create(authenticator=saml_authenticator)
65
    oidc_provider = OIDCProvider.objects.create(slug='oidc')
66
    oidc_claim_mapping = OIDCClaimMapping.objects.create(provider=oidc_provider)
64 67

  
65 68
    class EventFactory:
66 69
        date = make_aware(datetime.datetime(2020, 1, 1))
......
322 325
        session=session2,
323 326
        related_object=rename_attribute_action,
324 327
    )
328
    make('authenticator.oidc.claim.creation', user=agent, session=session2, claim=oidc_claim_mapping)
329
    claim_edit_form = mock.Mock(spec=['instance', 'initial', 'changed_data', 'cleaned_data'])
330
    claim_edit_form.instance = oidc_claim_mapping
331
    claim_edit_form.initial = {'claim': 'email'}
332
    claim_edit_form.changed_data = ['claim']
333
    claim_edit_form.cleaned_data = {'claim': 'first_name'}
334
    make('authenticator.oidc.claim.edit', user=agent, session=session2, form=claim_edit_form)
335
    make('authenticator.oidc.claim.deletion', user=agent, session=session2, claim=oidc_claim_mapping)
325 336

  
326 337
    # verify we created at least one event for each type
327 338
    assert set(Event.objects.values_list("type__name", flat=True)) == set(_registry)
......
359 370
def test_global_journal(app, superuser, events):
360 371
    response = login(app, user=superuser, path="/manage/")
361 372
    rename_attribute_action = RenameAttributeAction.objects.get()
373
    oidc_claim_mapping = OIDCClaimMapping.objects.get()
362 374

  
363 375
    # remove event about admin login
364 376
    Event.objects.filter(user=superuser).delete()
......
735 747
            'type': 'authenticator.saml.related_object.deletion',
736 748
            'user': 'agent',
737 749
        },
750
        {
751
            'message': 'creation of claim (%s) in provider "OpenIDConnect"' % oidc_claim_mapping.pk,
752
            'timestamp': 'Jan. 3, 2020, 11 a.m.',
753
            'type': 'authenticator.oidc.claim.creation',
754
            'user': 'agent',
755
        },
756
        {
757
            'message': 'edit claim (%s) in provider "OpenIDConnect" (claim)' % oidc_claim_mapping.pk,
758
            'timestamp': 'Jan. 3, 2020, noon',
759
            'type': 'authenticator.oidc.claim.edit',
760
            'user': 'agent',
761
        },
762
        {
763
            'message': 'deletion of claim (%s) in provider "OpenIDConnect"' % oidc_claim_mapping.pk,
764
            'timestamp': 'Jan. 3, 2020, 1 p.m.',
765
            'type': 'authenticator.oidc.claim.deletion',
766
            'user': 'agent',
767
        },
738 768
    ]
739 769

  
740 770
    agent_page = response.click('agent', index=1)
741
-