From 607b080567b764fd3f7a8186b32765881671cc46 Mon Sep 17 00:00:00 2001
From: Nicolas ROCHE <nroche@entrouvert.com>
Date: Thu, 16 Jul 2020 17:31:03 +0200
Subject: [PATCH] idp_oidc: revoke oidc claims authorization (#45200)

---
 src/authentic2/views.py                       |  2 +
 .../authorized_oauth_apps.html                | 49 +++++++++++++++++++
 src/authentic2_idp_oidc/urls.py               |  3 ++
 src/authentic2_idp_oidc/views.py              | 31 +++++++++++-
 tests/test_idp_oidc.py                        | 43 ++++++++++++++++
 5 files changed, 127 insertions(+), 1 deletion(-)
 create mode 100644 src/authentic2_idp_oidc/templates/authentic2_idp_oidc/authorized_oauth_apps.html

diff --git a/src/authentic2/views.py b/src/authentic2/views.py
index 78eb6760..2466cfb6 100644
--- a/src/authentic2/views.py
+++ b/src/authentic2/views.py
@@ -50,16 +50,17 @@ from django.contrib.auth import get_user_model
 from django.http import Http404
 from django.utils.http import urlsafe_base64_decode
 from django.views.generic.edit import CreateView
 from django.forms import CharField
 from django.http import HttpResponseBadRequest
 from django.template import loader
 
 from authentic2.compat.misc import default_token_generator
+from authentic2_idp_oidc.models import OIDCAuthorization
 from . import (utils, app_settings, decorators, constants,
                models, cbv, hooks, validators)
 from .utils import switch_user
 from .a2_rbac.utils import get_default_ou
 from .a2_rbac.models import OrganizationalUnit as OU
 from .forms import (
     passwords as passwords_forms,
     registration as registration_forms,
@@ -501,16 +502,17 @@ 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,
+            'oidc_authorizations': OIDCAuthorization.objects.filter(user=self.request.user),
             # 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())
diff --git a/src/authentic2_idp_oidc/templates/authentic2_idp_oidc/authorized_oauth_apps.html b/src/authentic2_idp_oidc/templates/authentic2_idp_oidc/authorized_oauth_apps.html
new file mode 100644
index 00000000..749f6e5e
--- /dev/null
+++ b/src/authentic2_idp_oidc/templates/authentic2_idp_oidc/authorized_oauth_apps.html
@@ -0,0 +1,49 @@
+{% extends "authentic2/base-page.html" %}
+{% load i18n %}
+
+{% block content %}
+{% block oidc-authorized-oauth-apps-pre %}{% endblock %}
+<div id="oidc-authorized-oauth-apps" class="authorized-oauth-apps">
+  {% block oidc-authorized-oauth-apps-top %}{% endblock %}
+  {% if authorized_oauth_apps|length_is:0 %}
+  {% trans "You have no granted application access to your account." %}
+  {% else %}
+  <form method="post">
+    {% blocktrans with len=authorized_oauth_apps|length %}
+    You have granted {{ len }} applications access to your account.
+    {% endblocktrans %}
+    {% csrf_token %}
+    <input type="hidden" id="auth-id" name="auth_id" value="">
+    <input type="submit" value="{% trans 'Revoke all' %}">
+  </form>
+  <table>
+    <thead>
+      <tr>
+        <td>{% trans "Application" %}</td>
+        <td>{% trans "Attributs" %}</td>
+        <td>{% trans "From" %}</td>
+        <td>{% trans "To" %}</td>
+        <td></td>
+      </tr>
+    </thead>
+    {% for auth in authorized_oauth_apps %}
+    <tr>
+      <td>{{ auth.client }}</td>
+      <td>{{ auth.scopes }}</td>
+      <td>{{ auth.created }}</td>
+      <td>{{ auth.expired }}</td>
+      <td>
+        <form method="post">
+          {% csrf_token %}
+          <input type="hidden" id="auth-id" name="auth_id" value="{{ auth.id }}">
+          <input type="submit" value="{% trans 'Revoke' %}">
+        </form>
+      </td>
+    </tr>
+    {% endfor %}
+  </table>
+  {% endif %}
+  {% block oidc-authorized-oauth-apps-bottom %}{% endblock %}
+</div>
+{% block oidc-authorized-oauth-apps-post %}{% endblock %}
+{% endblock %}
diff --git a/src/authentic2_idp_oidc/urls.py b/src/authentic2_idp_oidc/urls.py
index b5f0ffae..a15ae3b6 100644
--- a/src/authentic2_idp_oidc/urls.py
+++ b/src/authentic2_idp_oidc/urls.py
@@ -24,16 +24,19 @@ urlpatterns = [
         views.openid_configuration,
         name='oidc-openid-configuration'),
     url(r'^idp/oidc/certs/$',
         views.certs,
         name='oidc-certs'),
     url(r'^idp/oidc/authorize/$',
         views.authorize,
         name='oidc-authorize'),
+    url(r'^idp/oidc/authorized_oauth_apps/$',
+        views.authorized_oauth_apps,
+        name='oidc-authorized-oauth-apps'),
     url(r'^idp/oidc/token/$',
         views.token,
         name='oidc-token'),
     url(r'^idp/oidc/user_info/$',
         views.user_info,
         name='oidc-user-info'),
     url(r'^idp/oidc/logout/$',
         views.logout,
diff --git a/src/authentic2_idp_oidc/views.py b/src/authentic2_idp_oidc/views.py
index e8b00322..bff72ab9 100644
--- a/src/authentic2_idp_oidc/views.py
+++ b/src/authentic2_idp_oidc/views.py
@@ -15,26 +15,28 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import logging
 import math
 import datetime
 import base64
 import time
 
-from django.http import (HttpResponse, HttpResponseNotAllowed, JsonResponse)
+from django.http import (HttpResponse, HttpResponseNotAllowed, HttpResponseRedirect, JsonResponse)
 from django.urls import reverse
 from django.utils import six
 from django.utils.encoding import force_text
 from django.utils.timezone import now, utc
 from django.utils.http import urlencode
 from django.shortcuts import render
 from django.views.decorators.csrf import csrf_exempt
+from django.views.generic import TemplateView
 from django.contrib import messages
 from django.contrib.auth import authenticate
+from django.contrib.auth.decorators import login_required
 from django.conf import settings
 from django.utils.translation import ugettext as _
 from ratelimit.utils import is_ratelimited
 
 from authentic2 import app_settings as a2_app_settings
 from authentic2.compat.misc import Base64Error
 from authentic2.decorators import setting_enabled
 from authentic2.exponential_retry_timeout import ExponentialRetryTimeout
@@ -361,16 +363,43 @@ def authorize(request, *args, **kwargs):
             })
         # query is transfered through the hashtag
         response = redirect(request, redirect_uri + '#%s' % urlencode(params), resolve=False)
     hooks.call_hooks('event', name='sso-success', idp='oidc', service=client, user=request.user)
     utils.add_oidc_session(request, client)
     return response
 
 
+class AuthorizedOauthAppsView(TemplateView):
+    template_name = 'authentic2_idp_oidc/authorized_oauth_apps.html'
+    title = _('Granted Applications')
+
+    def dispatch(self, request, *args, **kwargs):
+        return login_required(
+            super(AuthorizedOauthAppsView, self).dispatch)(request, *args, **kwargs)
+
+    def get_context_data(self, **kwargs):
+        context = super(AuthorizedOauthAppsView, self).get_context_data(**kwargs)
+        context['authorized_oauth_apps'] = models.OIDCAuthorization.objects.filter(
+            user=self.request.user)
+        return context
+
+    def post(self, request, *args, **kwargs):
+        if request.user:
+            qs = models.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('oidc-authorized-oauth-apps'))
+
+
+authorized_oauth_apps = AuthorizedOauthAppsView.as_view()
+
+
 def authenticate_client(request, client=None):
     '''Authenticate client on the token endpoint'''
 
     if 'HTTP_AUTHORIZATION' in request.META:
         authorization = request.META['HTTP_AUTHORIZATION'].split()
         if authorization[0] != 'Basic' or len(authorization) != 2:
             return None
         try:
diff --git a/tests/test_idp_oidc.py b/tests/test_idp_oidc.py
index a9517fe0..3121c1ec 100644
--- a/tests/test_idp_oidc.py
+++ b/tests/test_idp_oidc.py
@@ -1599,8 +1599,51 @@ 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_apps_views(app, oidc_client, simple_user):
+    url = make_url('oidc-authorized-oauth-apps')
+    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 no granted application access to your account." 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 applications access to your account." in response.text
+    assert len(response.html.find_all('form')) == 4
+
+    revoke_all_form = response.html.find_all('form')[0]
+    assert revoke_all_form.find('input', {"type": "submit"}).attrs['value'] == 'Revoke all'
+    assert revoke_all_form.find('input', {"name": "auth_id"}).attrs['value'] == ""
+
+    revoke_one_forms = response.html.find_all('form')[1:]
+    assert [x.find('input', {"type": "submit"}).attrs['value']
+            for x in revoke_one_forms] == ['Revoke'] * 3
+
+    # revoke one
+    response = response.forms[1].submit()
+    response = response.follow()
+    assert "You have granted 2 applications access to your account." in response.text
+    assert len(response.html.find_all('form')) == 3
+
+    # revoke all
+    response = response.forms[0].submit()
+    response = response.follow()
+    assert "You have no granted application access to your account." in response.text
-- 
2.27.0

