From c5289949d67dbe38d0e483784200b211f09ba94b Mon Sep 17 00:00:00 2001
From: Nicolas ROCHE <nroche@entrouvert.com>
Date: Thu, 16 Jul 2020 17:31:03 +0200
Subject: [PATCH] profile_views: add a profil page to manage authorized oauth
 services (#45200)

---
 src/authentic2/app_settings.py                |  3 +
 .../templates/authentic2/accounts.html        |  3 +
 .../accounts_authorized_oauth_services.html   | 67 +++++++++++++++++++
 src/authentic2/urls.py                        |  3 +
 src/authentic2/views.py                       | 29 ++++++++
 tests/test_idp_oidc.py                        | 36 ++++++++++
 tests/test_profile.py                         | 31 +++++++++
 7 files changed, 172 insertions(+)
 create mode 100644 src/authentic2/templates/authentic2/accounts_authorized_oauth_services.html

diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py
index 3e37b54e..a2ebae4c 100644
--- a/src/authentic2/app_settings.py
+++ b/src/authentic2/app_settings.py
@@ -122,16 +122,19 @@ default_settings = dict(
         default=True,
         definition='Can user self change their email'),
     A2_PROFILE_CAN_EDIT_PROFILE=Setting(
         default=True,
         definition='Can user self edit their profile'),
     A2_PROFILE_CAN_MANAGE_FEDERATION=Setting(
         default=True,
         definition='Can user manage its federations'),
+    A2_PROFILE_CAN_MANAGE_SERVICE_AUTHORIZATIONS=Setting(
+        default=True,
+        definition='Allow user to revoke granted services access to its account profile data'),
     A2_PROFILE_DISPLAY_EMPTY_FIELDS=Setting(
         default=False,
         definition='Include empty fields in profile view'),
     A2_HOMEPAGE_URL=Setting(
         default=None,
         definition='IdP has no homepage, redirect to this one.'),
     A2_USER_CAN_RESET_PASSWORD=Setting(
         default=None,
diff --git a/src/authentic2/templates/authentic2/accounts.html b/src/authentic2/templates/authentic2/accounts.html
index dff3645f..d5cc82f6 100644
--- a/src/authentic2/templates/authentic2/accounts.html
+++ b/src/authentic2/templates/authentic2/accounts.html
@@ -32,16 +32,19 @@
         </dl>
       {% endif %}
       {% if allow_email_change %}
         <p><a href="{% url 'email-change' %}">{% trans "Change email" %}</a></p>
       {% endif %}
       {% if allow_profile_edit %}
         <p><a href="{% url 'profile_edit' %}">{% trans "Edit account data" %}</a></p>
       {% endif %}
+      {% if allow_authorization_management %}
+        <p><a href="{% url 'authorized-oauth-services' %}">{% trans "Manage service authorizations" %}</a></p>
+      {% endif %}
       {% if allow_account_deletion %}
         <p><a href="{% url 'delete_account' %}">{% trans "Delete account" %}</a></p>
       {% endif %}
     </div>
     <div id="a2-credentials" class="a2-profile-block">
       <h3>{% trans "Credentials" %}</h3>
       {% for html_block in frontends_block %}
         {{ html_block|safe }}
diff --git a/src/authentic2/templates/authentic2/accounts_authorized_oauth_services.html b/src/authentic2/templates/authentic2/accounts_authorized_oauth_services.html
new file mode 100644
index 00000000..be72fa42
--- /dev/null
+++ b/src/authentic2/templates/authentic2/accounts_authorized_oauth_services.html
@@ -0,0 +1,67 @@
+{% extends "authentic2/base-page.html" %}
+{% load i18n %}
+
+{% block page-title %}
+{{ block.super }} - {{ view.title }}
+{% endblock %}
+
+{% block breadcrumb %}
+{{ block.super }}
+<a href="..">{% trans "Your account" %}</a>
+<a href="">{{ view.title }}</a>
+{% endblock %}
+
+{% block content %}
+{% block oidc-authorized-oauth-services-pre %}{% endblock %}
+<div class="authorized-oauth-services">
+  {% block oidc-authorized-oauth-services-top %}
+  <p class="authorized-oauth-services--top">
+    {% if authorized_oauth_services|length_is:0 %}
+    {% trans "You have not granted service access to your account profile data." %}
+    {% else %}
+    {% blocktrans count counter=authorized_oauth_services|length %}
+    You have granted one service access to your account profile data.
+    {% plural %}
+    You have granted {{ counter }} services access to your account profile data.
+    {% endblocktrans %}
+    {% endif %}
+  </p>
+  {% endblock %}
+  <ul class="authorized-oauth-services--list">
+    {% for auth in authorized_oauth_services %}
+    <li class="authorized-oauth-services--item">
+      <form method="post" class="authorized-oauth-services--form">
+        {% csrf_token %}
+        {% block oidc-authorized-oauth-service %}
+        <div class="authorized-oauth-services--infos">
+          {% block oidc-authorized-oauth-service-top %}{% endblock %}
+          <span class="authorized-oauth-services--client">
+            {{ auth.client }}
+          </span>
+          <span class="authorized-oauth-services--dates">
+            <span class="authorized-oauth-services-dates--since">
+              <span class="label">{% trans "Allowed since:" %}</span>
+              <span class="time">{{ auth.created }}</span>
+            </span>
+            <span class="authorized-oauth-services--separator">/</span>
+            <span class="authorized-oauth-services--expired">
+              <span class="label">{% trans "Expire on:" %}</span>
+              <span class="time">{{ auth.expired }}</span>
+            </span>
+          </span>
+        </div>
+        <div class="authorized-oauth-services--actions">
+          <input type="hidden" id="auth-id" name="auth_id" value="{{ auth.id }}">
+          <button class="authorized-oauth-services--revoke-button">{% trans 'Revoke' %}</button>
+        </div>
+        {% block oidc-authorized-oauth-service-bottom %}{% endblock %}
+        {% endblock %}
+      </form>
+    </li>
+    {% endfor %}
+  </ul>
+</table>
+{% block oidc-authorized-oauth-services-bottom %}{% endblock %}
+</div>
+{% block oidc-authorized-oauth-services-post %}{% endblock %}
+{% endblock %}
diff --git a/src/authentic2/urls.py b/src/authentic2/urls.py
index 1e5b4bbf..66c40be3 100644
--- a/src/authentic2/urls.py
+++ b/src/authentic2/urls.py
@@ -64,16 +64,19 @@ accounts_urlpatterns = [
         views.edit_profile,
         name='profile_edit_with_scope'),
     url(r'^change-email/$',
         views.email_change,
         name='email-change'),
     url(r'^change-email/verify/$',
         views.email_change_verify,
         name='email-change-verify'),
+    url(r'^authorizations/$',
+        login_required(views.authorized_oauth_services),
+        name='authorized-oauth-services'),
     url(r'^$',
         views.profile,
         name='account_management'),
 
     # Password change
     url(r'^password/change/$',
         views.password_change,
         name='password_change'),
diff --git a/src/authentic2/views.py b/src/authentic2/views.py
index 78eb6760..934d4f3c 100644
--- a/src/authentic2/views.py
+++ b/src/authentic2/views.py
@@ -501,16 +501,19 @@ class ProfileView(cbv.TemplateNamesMixin, TemplateView):
         context.update({
             'frontends_block': blocks,
             'frontends_block_by_id': blocks_by_id,
             'profile': profile,
             'attributes': attributes,
             'allow_account_deletion': app_settings.A2_REGISTRATION_CAN_DELETE_ACCOUNT,
             'allow_profile_edit': EditProfile.can_edit_profile(),
             'allow_email_change': app_settings.A2_PROFILE_CAN_CHANGE_EMAIL,
+            'allow_authorization_management': (
+                app_settings.A2_PROFILE_CAN_MANAGE_SERVICE_AUTHORIZATIONS
+                and 'authentic2_idp_oidc' in settings.INSTALLED_APPS),
             # TODO: deprecated should be removed when publik-base-theme is updated
             'allow_password_change': utils.user_can_change_password(request=request),
             'federation_management': federation_management,
         })
         hooks.call_hooks('modify_context_data', self, context)
         return context
 
 profile = login_required(ProfileView.as_view())
@@ -1264,8 +1267,34 @@ def notimplemented_view(request):
 class SuView(View):
     def get(self, request, uuid):
         user = switch_user.resolve_token(uuid)
         if not user:
             raise Http404
         return utils.simulate_authentication(request, user, 'su')
 
 su = SuView.as_view()
+
+
+class AuthorizedOauthServicesView(TemplateView):
+    template_name = 'authentic2/accounts_authorized_oauth_services.html'
+    title = _('Consent Management')
+
+    def get_context_data(self, **kwargs):
+        from authentic2_idp_oidc.models import OIDCAuthorization
+
+        context = super(AuthorizedOauthServicesView, self).get_context_data(**kwargs)
+        context['authorized_oauth_services'] = OIDCAuthorization.objects.filter(
+            user=self.request.user)
+        return context
+
+    def post(self, request, *args, **kwargs):
+        from authentic2_idp_oidc.models import OIDCAuthorization
+
+        qs = OIDCAuthorization.objects.filter(user=request.user)
+        auth_id = request.POST.get('auth_id')
+        if auth_id:
+            qs = qs.filter(id=auth_id)
+        qs.delete()
+        return HttpResponseRedirect(reverse('authorized-oauth-services'))
+
+
+authorized_oauth_services = AuthorizedOauthServicesView.as_view()
diff --git a/tests/test_idp_oidc.py b/tests/test_idp_oidc.py
index a9517fe0..3cd2af20 100644
--- a/tests/test_idp_oidc.py
+++ b/tests/test_idp_oidc.py
@@ -1599,8 +1599,44 @@ def test_oidc_client_clean():
     with pytest.raises(ValidationError, match=r'same domain'):
         OIDCClient(
             redirect_uris='https://example.com/ https://example2.com/',
             identifier_policy=OIDCClient.POLICY_PAIRWISE).clean()
 
     OIDCClient(
         redirect_uris='https://example.com/ https://example2.com/',
         sector_identifier_uri='https://example.com/').clean()
+
+
+def test_oidc_authorized_oauth_services_view(app, oidc_client, simple_user):
+    url = make_url('authorized-oauth-services')
+    response = app.get(url, status=302)
+    assert '/login/' in response.location
+
+    utils.login(app, simple_user)
+    response = app.get(url, status=200)
+    assert "You have not granted service access to your account profile data." in response.text
+
+    OIDCAuthorization.objects.create(
+        client=oidc_client, user=simple_user, scopes='openid',
+        expired=now() + datetime.timedelta(days=2))
+    OIDCAuthorization.objects.create(
+        client=oidc_client, user=simple_user, scopes='openid profile',
+        expired=now() + datetime.timedelta(days=2))
+    OIDCAuthorization.objects.create(
+        client=oidc_client, user=simple_user, scopes='openid profile email',
+        expired=now() + datetime.timedelta(days=2))
+
+    response = app.get(url, status=200)
+    assert "You have granted 3 services access to your account profile data."
+    assert len(response.html.find_all(
+        'button', {'class': 'authorized-oauth-services--revoke-button'})) == 3
+
+    # revoke two
+    response = response.forms[0].submit()
+    response = response.follow()
+    assert len(response.html.find_all(
+        'button', {'class': 'authorized-oauth-services--revoke-button'})) == 2
+    response = response.forms[0].submit()
+    response = response.follow()
+    assert len(response.html.find_all(
+        'button', {'class': 'authorized-oauth-services--revoke-button'})) == 1
+    assert "You have granted one service access to your account profile data." in response.text
diff --git a/tests/test_profile.py b/tests/test_profile.py
index ad0b97dc..0654b8f2 100644
--- a/tests/test_profile.py
+++ b/tests/test_profile.py
@@ -187,8 +187,39 @@ def test_account_edit_locked_title(app, simple_user):
     assert len(response.pyquery('input[type="radio"][name="edit-profile-title"][readonly="true"]')) == 0
     assert len(response.pyquery('select[name="edit-profile-title"]')) == 0
 
     simple_user.verified_attributes.title = 'Monsieur'
 
     response = app.get(url, status=200)
     assert len(response.pyquery('input[type="radio"][name="edit-profile-title"]')) == 0
     assert len(response.pyquery('input[type="text"][name="edit-profile-title@disabled"][readonly]')) == 1
+
+
+def test_acount_view(app, simple_user, settings):
+    utils.login(app, simple_user)
+    url = reverse('account_management')
+    response = app.get(url, status=200)
+    assert [x['href'] for x in response.html.find('div', {'id': 'a2-profile'}).find_all('a')] == [
+        reverse('email-change'),
+        reverse('profile_edit'),
+        reverse('authorized-oauth-services'),
+        reverse('delete_account')
+    ]
+
+    settings.INSTALLED_APPS = tuple(x for x in settings.INSTALLED_APPS if x != 'authentic2_idp_oidc')
+    url = reverse('account_management')
+    response = app.get(url, status=200)
+    assert [x['href'] for x in response.html.find('div', {'id': 'a2-profile'}).find_all('a')] == [
+        reverse('email-change'),
+        reverse('profile_edit'),
+        reverse('delete_account')
+    ]
+    settings.INSTALLED_APPS += ('authentic2_idp_oidc',)
+
+    settings.A2_PROFILE_CAN_CHANGE_EMAIL = False
+    settings.A2_PROFILE_CAN_MANAGE_SERVICE_AUTHORIZATIONS = False
+    settings.A2_REGISTRATION_CAN_DELETE_ACCOUNT = False
+    url = reverse('account_management')
+    response = app.get(url, status=200)
+    assert [x['href'] for x in response.html.find('div', {'id': 'a2-profile'}).find_all('a')] == [
+        reverse('profile_edit'),
+    ]
-- 
2.27.0

