0002-auth_oidc-add-views-to-configure-claims-66419.patch
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 |
- |