From 7150b9b15e0c5dac604c6b9c9cd0cd1f255d4db3 Mon Sep 17 00:00:00 2001
From: Serghei Mihai <smihai@entrouvert.com>
Date: Thu, 12 May 2022 17:27:06 +0200
Subject: [PATCH] manager: add OpenID service handling (#20696)

---
 src/authentic2/manager/forms.py               |  27 ++++
 src/authentic2/manager/service_views.py       | 118 +++++++++++++++++-
 .../manager/oidc_service_detail.html          |  31 +++++
 .../templates/authentic2/manager/service.html |  14 ++-
 .../authentic2/manager/services.html          |   9 ++
 src/authentic2/manager/urls.py                |  29 ++++-
 src/authentic2/manager/views.py               |   1 +
 src/authentic2_idp_oidc/app_settings.py       |  22 ++++
 tests/test_manager.py                         |  92 ++++++++++++++
 9 files changed, 337 insertions(+), 6 deletions(-)
 create mode 100644 src/authentic2/manager/templates/authentic2/manager/oidc_service_detail.html

diff --git a/src/authentic2/manager/forms.py b/src/authentic2/manager/forms.py
index 8cfde240c..06859953b 100644
--- a/src/authentic2/manager/forms.py
+++ b/src/authentic2/manager/forms.py
@@ -32,9 +32,11 @@ 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.attributes_ng.engine import get_service_attributes
 from authentic2.forms.fields import CheckPasswordField, NewPasswordField, ValidatedEmailField
 from authentic2.forms.mixins import SlugMixin
 from authentic2.forms.profile import BaseUserForm
+from authentic2.forms.widgets import DatalistTextInput
 from authentic2.models import PasswordReset
 from authentic2.passwords import generate_password
 from authentic2.utils.misc import (
@@ -43,6 +45,8 @@ from authentic2.utils.misc import (
     send_password_reset_mail,
     send_templated_mail,
 )
+from authentic2_idp_oidc import app_settings as oidc_app_settings
+from authentic2_idp_oidc.models import OIDCClaim, OIDCClient
 from django_rbac.backends import DjangoRBACBackend
 from django_rbac.models import Operation
 
@@ -897,3 +901,26 @@ class ChooseUserOrRoleForm(FormWithRequest, forms.Form):
 
         perm = '%s.search_%s' % (User._meta.app_label, User._meta.model_name)
         return user.filter_by_perm(perm, qs)
+
+
+class OIDCServiceForm(SlugMixin, forms.ModelForm):
+    class Meta:
+        model = OIDCClient
+        fields = oidc_app_settings.MANAGER_FIELDS
+
+
+class OIDCClaimForm(forms.ModelForm):
+    class Meta:
+        model = OIDCClaim
+        fields = ('name', 'value', 'scopes')
+        widgets = {
+            'value': DatalistTextInput,
+        }
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        data = dict(get_service_attributes(getattr(self.instance, 'client', None))).keys()
+        widget = self.fields['value'].widget
+        widget.data = data
+        widget.name = 'list__oidcclaim-inline'
+        widget.attrs.update({'list': 'list__oidcclaim-inline'})
diff --git a/src/authentic2/manager/service_views.py b/src/authentic2/manager/service_views.py
index 372fc63e3..70fbae139 100644
--- a/src/authentic2/manager/service_views.py
+++ b/src/authentic2/manager/service_views.py
@@ -15,9 +15,13 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 from django.contrib import messages
+from django.template.loader import render_to_string
+from django.urls import reverse
 from django.utils.translation import ugettext as _
 
 from authentic2.models import Service
+from authentic2_idp_oidc import app_settings as oidc_app_settings
+from authentic2_idp_oidc.models import OIDCClaim, OIDCClient
 
 from . import forms, role_views, tables, views
 
@@ -82,14 +86,38 @@ class ServiceView(
             messages.warning(self.request, _('You are not authorized'))
         return super().form_valid(form)
 
+    def get_service_data(self, service, fields):
+        for field in fields:
+            field_value = getattr(service, field)
+            if not field_value:
+                continue
+            if hasattr(service, 'get_%s_display' % field):
+                field_value = getattr(service, 'get_%s_display' % field)()
+            yield service._meta.get_field(field).verbose_name, field_value
+
+    def get_oidc_service_data(self):
+        if not hasattr(self.object, 'oidcclient'):
+            return {}
+        service = self.object.oidcclient
+        # add client id and secret, but remove name because it's already displayed
+        display_fields = ['client_id', 'client_secret'] + oidc_app_settings.MANAGER_FIELDS[1:]
+        ctx = {
+            'service_fields': self.get_service_data(service, display_fields),
+            'claims': service.oidcclaim_set.all(),
+            'service': service,
+        }
+        self.can_delete = True
+        return {'service_block': render_to_string('authentic2/manager/oidc_service_detail.html', ctx)}
+
     def get_context_data(self, **kwargs):
         kwargs['form'] = self.get_form()
         ctx = super().get_context_data(**kwargs)
         ctx['roles_table'] = tables.RoleTable(self.object.roles.all())
+        ctx.update(self.get_oidc_service_data())
         return ctx
 
 
-roles = ServiceView.as_view()
+service_detail = ServiceView.as_view()
 
 
 class ServiceEditView(views.BaseEditView):
@@ -101,5 +129,91 @@ class ServiceEditView(views.BaseEditView):
     fields = ['name', 'slug', 'ou', 'unauthorized_url']
     success_url = '..'
 
+    def get_object(self, queryset=None):
+        service = super().get_object(queryset)
+        if hasattr(service, 'oidcclient'):
+            self.model = OIDCClient
+            return service.oidcclient
+        return service
+
+    def get_fields(self):
+        fields = super().get_fields()
+        if hasattr(self.object, 'oidcclient'):
+            fields.extend(oidc_app_settings.MANAGER_FIELDS)
+        return fields
+
+
+edit_service = ServiceEditView.as_view()
+
+
+class ServiceDeleteView(views.BaseDeleteView):
+    model = Service
+    pk_url_kwarg = 'service_pk'
+    permissions = ['authentic2.delete_service']
+    title = _('Delete OpenID Service')
+
+
+delete_service = ServiceDeleteView.as_view()
+
+
+class OIDCServiceAddView(views.ActionMixin, views.BaseAddView):
+    form_class = forms.OIDCServiceForm
+    model = OIDCClient
+    title = _('Add OpenID service')
+    permissions = ['authentic2.add_service']
+    action = _('Add')
+
+    def get_success_url(self):
+        # add default claims mappings after creating service
+        for mapping in oidc_app_settings.DEFAULT_MAPPINGS:
+            OIDCClaim.objects.get_or_create(client=self.object, **mapping)
+        return reverse('a2-manager-service', kwargs={'service_pk': self.object.pk})
+
+
+add_oidc_service = OIDCServiceAddView.as_view()
+
+
+class OIDCClaimAddView(views.ActionMixin, views.BaseAddView):
+    form_class = forms.OIDCClaimForm
+    model = OIDCClaim
+    title = _('Add OpenID Claim')
+    action = _('Add')
+
+    def form_valid(self, form):
+        obj = form.save(commit=False)
+        obj.client = OIDCClient.objects.get(pk=self.kwargs['service_pk'])
+        obj.save()
+        return super().form_valid(form)
+
+    def get_success_url(self):
+        return reverse('a2-manager-service', kwargs={'service_pk': self.object.client.pk})
+
+
+oidc_claim_add = OIDCClaimAddView.as_view()
+
+
+class BaseClaimView:
+    model = OIDCClaim
+    pk_url_kwarg = 'claim_pk'
+
+    def get_queryset(self):
+        qs = super().get_queryset()
+        return qs.filter(client__pk=self.kwargs['service_pk'])
+
+    def get_success_url(self):
+        return reverse('a2-manager-service', kwargs={'service_pk': self.object.client.pk})
+
+
+class OIDCClaimEditView(BaseClaimView, views.BaseEditView):
+    title = _('Edit OpenID Claim')
+    form_class = forms.OIDCClaimForm
+
+
+oidc_claim_edit = OIDCClaimEditView.as_view()
+
+
+class OIDCClaimDeleteView(BaseClaimView, views.BaseDeleteView):
+    title = _('Delete OpenID Claim')
+
 
-edit = ServiceEditView.as_view()
+oidc_claim_delete = OIDCClaimDeleteView.as_view()
diff --git a/src/authentic2/manager/templates/authentic2/manager/oidc_service_detail.html b/src/authentic2/manager/templates/authentic2/manager/oidc_service_detail.html
new file mode 100644
index 000000000..c10cc7a1a
--- /dev/null
+++ b/src/authentic2/manager/templates/authentic2/manager/oidc_service_detail.html
@@ -0,0 +1,31 @@
+{% load i18n %}
+{% for field, value in service_fields %}
+  <p>{{ field|capfirst }}{% trans ":" %}  {% if value == True %}{% trans "yes" %}
+  {% elif value == False %}{% trans "no" %}
+  {% else %}{{value}}
+  {% endif %}</p>
+{% endfor %}
+
+<div class="section">
+  <h3>{% trans "Claims" %}<a href="{% url "a2-manager-oidc-claim-add" service_pk=service.pk %}" class="button" rel="popup">Add claim</a></h3>
+  {% if claims %}
+  <table class="main plaintable objects-table" id="oidc-claims">
+    <thead>
+      <tr><th>{% trans "Name" %}</th><th>{% trans "Value" %}</th><th>{% trans "Scopes" %}</th><th></th></tr>
+    </thead>
+    <tbody>
+    {% for claim in claims %}
+    <tr>
+      <td>{{ claim.name }}</td>
+      <td>{{ claim.value }}</td>
+      <td>{{ claim.scopes }}</td>
+      <td class="actions">
+        <a class="edit" href="{% url "a2-manager-oidc-claim-edit" service_pk=service.pk claim_pk=claim.pk %}" rel="popup" title="{% trans "Edit" %}">{% trans "Edit" %}</a>
+        <a class="delete" href="{% url "a2-manager-oidc-claim-delete" service_pk=service.pk claim_pk=claim.pk %}" rel="popup" title="{% trans "Delete" %}">{% trans "Delete" %}</a>
+      </td>
+    </tr>
+    {% endfor %}
+    </tbody>
+  </table>
+  {% endif %}
+</div>
diff --git a/src/authentic2/manager/templates/authentic2/manager/service.html b/src/authentic2/manager/templates/authentic2/manager/service.html
index 7fb3a3dfc..e1501d287 100644
--- a/src/authentic2/manager/templates/authentic2/manager/service.html
+++ b/src/authentic2/manager/templates/authentic2/manager/service.html
@@ -1,16 +1,23 @@
-{% extends "authentic2/manager/services.html" %}
+{% extends "authentic2/manager/base.html" %}
 {% load i18n static django_tables2 %}
 
 {% block page-title %}{% firstof manager_site_title site_title "Authentic2" %} - {{ object }}{% endblock %}
 
 {% block breadcrumb %}
-  {{ block.super }}
+{{ block.super }}
+  <a href="{% url 'a2-manager-services' %}">{% trans 'Services' %}</a>
   <a href="{% url 'a2-manager-service' service_pk=view.kwargs.service_pk %}">{{ view.service.name }}</a>
 {% endblock %}
 
+{% block buttons %}
+{% endblock %}
+
 {% block appbar %}
   {{ block.super }}
   <span class="actions">
+  {% if view.can_delete %}
+  <a rel="popup" href="{% url "a2-manager-service-delete" service_pk=view.kwargs.service_pk %}">{% trans "Delete" %}</a>
+  {% endif %}
   {% if view.can_change %}
   <a rel="popup" href="{% url "a2-manager-service-edit" service_pk=view.kwargs.service_pk %}">{% trans "Edit" %}</a>
   {% endif %}
@@ -34,6 +41,9 @@
 {% endblock %}
 
 {% block main %}
+
+{{ service_block|safe }}
+
 <div class="section">
   <h3>{% trans "Roles of users allowed on this service" %}</h3>
   <div id="authorized-roles">
diff --git a/src/authentic2/manager/templates/authentic2/manager/services.html b/src/authentic2/manager/templates/authentic2/manager/services.html
index a28d518dd..12d94a869 100644
--- a/src/authentic2/manager/templates/authentic2/manager/services.html
+++ b/src/authentic2/manager/templates/authentic2/manager/services.html
@@ -8,6 +8,15 @@
   <a href="{% url 'a2-manager-services' %}">{% trans 'Services' %}</a>
 {% endblock %}
 
+{% block appbar %}
+  {{ block.super }}
+  <span class="actions">
+    {% if view.can_add %}
+    <a href="{% url "a2-manager-add-oidc-service" %}">{% trans "Add OpenID service" %}</a>
+    {% endif %}
+  </span>
+{% endblock %}
+
 {% block sidebar %}
   <aside id="sidebar">
     {% include "authentic2/manager/search_form.html" %}
diff --git a/src/authentic2/manager/urls.py b/src/authentic2/manager/urls.py
index 9ec4bf1ff..668592e65 100644
--- a/src/authentic2/manager/urls.py
+++ b/src/authentic2/manager/urls.py
@@ -182,8 +182,33 @@ urlpatterns = required(
         url(r'^organizational-units/import/$', ou_views.ous_import, name='a2-manager-ous-import'),
         # Services
         url(r'^services/$', service_views.listing, name='a2-manager-services'),
-        url(r'^services/(?P<service_pk>\d+)/$', service_views.roles, name='a2-manager-service'),
-        url(r'^services/(?P<service_pk>\d+)/edit/$', service_views.edit, name='a2-manager-service-edit'),
+        url(r'^services/(?P<service_pk>\d+)/$', service_views.service_detail, name='a2-manager-service'),
+        url(r'^services/add-oidc/$', service_views.add_oidc_service, name='a2-manager-add-oidc-service'),
+        url(
+            r'^services/(?P<service_pk>\d+)/edit/$',
+            service_views.edit_service,
+            name='a2-manager-service-edit',
+        ),
+        url(
+            r'^services/(?P<service_pk>\d+)/delete/$',
+            service_views.delete_service,
+            name='a2-manager-service-delete',
+        ),
+        url(
+            r'^services/(?P<service_pk>\d+)/claim/add/$',
+            service_views.oidc_claim_add,
+            name='a2-manager-oidc-claim-add',
+        ),
+        url(
+            r'^services/(?P<service_pk>\d+)/claim/(?P<claim_pk>\d+)/edit/$',
+            service_views.oidc_claim_edit,
+            name='a2-manager-oidc-claim-edit',
+        ),
+        url(
+            r'^services/(?P<service_pk>\d+)/claim/(?P<claim_pk>\d+)/delete/$',
+            service_views.oidc_claim_delete,
+            name='a2-manager-oidc-claim-delete',
+        ),
         # Journal
         url(r'^journal/$', journal_views.journal, name='a2-manager-journal'),
         url(
diff --git a/src/authentic2/manager/views.py b/src/authentic2/manager/views.py
index 6e205660b..b44178b39 100644
--- a/src/authentic2/manager/views.py
+++ b/src/authentic2/manager/views.py
@@ -483,6 +483,7 @@ class ModelFormView(MediaMixin, FormNeedsRequest):
 
     fields = None
     form_class = None
+    model = None
 
     def get_fields(self):
         return self.fields
diff --git a/src/authentic2_idp_oidc/app_settings.py b/src/authentic2_idp_oidc/app_settings.py
index 42a050208..db7837535 100644
--- a/src/authentic2_idp_oidc/app_settings.py
+++ b/src/authentic2_idp_oidc/app_settings.py
@@ -83,6 +83,28 @@ class AppSettings:
     def PROFILE_OVERRIDE_MAPPING(self):
         return self._setting('PROFILE_OVERRIDE_MAPPING', {'email': 'email'})
 
+    @property
+    def MANAGER_FIELDS(self):
+        return self._setting(
+            'MANAGER_FIELDS',
+            [
+                'name',
+                'redirect_uris',
+                'post_logout_redirect_uris',
+                'sector_identifier_uri',
+                'frontchannel_logout_uri',
+                'ou',
+                'identifier_policy',
+                'idtoken_algo',
+                'unauthorized_url',
+                'authorization_mode',
+                'authorization_flow',
+                'home_url',
+                'colour',
+                'logo',
+            ],
+        )
+
 
 app_settings = AppSettings('A2_IDP_OIDC_')
 app_settings.__name__ = __name__
diff --git a/tests/test_manager.py b/tests/test_manager.py
index e17d9a955..21fce23d8 100644
--- a/tests/test_manager.py
+++ b/tests/test_manager.py
@@ -34,6 +34,8 @@ from authentic2.a2_rbac.utils import get_default_ou
 from authentic2.apps.journal.models import Event
 from authentic2.models import Service
 from authentic2.validators import EmailValidator
+from authentic2_idp_oidc import app_settings as oidc_app_settings
+from authentic2_idp_oidc.models import OIDCClaim, OIDCClient
 from django_rbac.models import VIEW_OP
 from django_rbac.utils import get_operation
 
@@ -1220,3 +1222,93 @@ def test_manager_service_search(app, admin, ou1):
     resp = resp.form.submit()
     assert 'Test Service' in resp.text
     assert 'Example Service' not in resp.text
+
+
+def test_manager_add_oidc_service(app, superuser):
+    resp = login(app, superuser, 'a2-manager-services')
+    assert 'Add OpenID service' in resp.text
+    assert OIDCClient.objects.count() == 0
+    assert OIDCClaim.objects.count() == 0
+
+    resp = resp.click('Add OpenID service')
+    form = resp.form
+    for field in oidc_app_settings.MANAGER_FIELDS:
+        assert field in form.fields
+
+    form['name'] = 'Test'
+    form['redirect_uris'] = 'http://example.com'
+    resp = form.submit()
+
+    assert OIDCClient.objects.count() == 1
+    assert OIDCClaim.objects.count() == len(oidc_app_settings.DEFAULT_MAPPINGS)
+    assert resp.location == reverse('a2-manager-service', kwargs={'service_pk': OIDCClient.objects.get().pk})
+
+    resp = resp.follow()
+    assert "<h3>Claims" in resp.text
+    assert "Add claim" in resp.text
+    assert resp.pyquery.remove_namespaces()('#oidc-claims tbody tr').length == len(
+        oidc_app_settings.DEFAULT_MAPPINGS
+    )
+
+
+def test_manager_edit_oidc_service(app, superuser):
+    OIDCClient.objects.create(name='Test', slug='test', redirect_uris='http://example.com')
+    resp = login(app, superuser, 'a2-manager-services')
+    resp = resp.click('Test')
+    resp = resp.click('Edit')
+    form = resp.form
+    form['name'] = 'New Test'
+    form['colour'] = '#ff00ff'
+    resp = form.submit()
+    assert resp.location == '..'
+    resp = resp.follow()
+    assert "New Test" in resp.text
+    assert "#ff00ff" in resp.text
+
+
+def test_manager_delete_oidc_service(app, superuser):
+    OIDCClient.objects.create(name='Test', slug='test', redirect_uris='http://example.com')
+    resp = login(app, superuser, 'a2-manager-services')
+    resp = resp.click('Test')
+    resp = resp.click('Delete')
+    resp = resp.form.submit().follow()
+    assert OIDCClient.objects.count() == 0
+
+
+def test_manager_add_oidc_claim(app, superuser):
+    client = OIDCClient.objects.create(name='Test', slug='test', redirect_uris='http://example.com')
+    resp = login(app, superuser, reverse('a2-manager-service', kwargs={'service_pk': client.pk}))
+    resp = resp.click('Add claim')
+    form = resp.form
+    form['name'] = 'claim'
+    form['value'] = 'value'
+    form['scopes'] = 'profile'
+    resp = form.submit()
+    assert resp.location == reverse('a2-manager-service', kwargs={'service_pk': client.pk})
+    assert OIDCClaim.objects.filter(client=client, name='claim', value='value', scopes='profile').exists()
+
+
+def test_manager_edit_oidc_claim(app, superuser):
+    client = OIDCClient.objects.create(name='Test', slug='test', redirect_uris='http://example.com')
+    OIDCClaim.objects.create(client=client, name='claim', value='value', scopes='profile')
+    resp = login(app, superuser, reverse('a2-manager-service', kwargs={'service_pk': client.pk}))
+    assert "claim" in resp.text
+    resp = resp.click('Edit', index=1)
+    form = resp.form
+    form['value'] = 'new value'
+    resp = form.submit()
+    assert resp.location == reverse('a2-manager-service', kwargs={'service_pk': client.pk})
+    assert not OIDCClaim.objects.filter(client=client, name='claim', value='value', scopes='profile').exists()
+    assert OIDCClaim.objects.filter(client=client, name='claim', value='new value', scopes='profile').exists()
+
+
+def test_manager_delete_oidc_claim(app, superuser):
+    client = OIDCClient.objects.create(name='Test', slug='test', redirect_uris='http://example.com')
+    OIDCClaim.objects.create(client=client, name='claim', value='value', scopes='profile')
+    resp = login(app, superuser, reverse('a2-manager-service', kwargs={'service_pk': client.pk}))
+    assert "claim" in resp.text
+    resp = resp.click('Delete', index=1)
+    form = resp.form
+    resp = form.submit()
+    assert resp.location == reverse('a2-manager-service', kwargs={'service_pk': client.pk})
+    assert not OIDCClaim.objects.filter(client=client, name='claim', value='value', scopes='profile').exists()
-- 
2.36.1

