Pre-consented clients are displayed in the user consent list, and their consents can be revoked.

This commit is contained in:
Éloi Rivard 2023-02-14 21:55:46 +01:00
parent d551b1ab35
commit 7458868f77
7 changed files with 219 additions and 24 deletions

View file

@ -9,6 +9,8 @@ Added
- Display TOS and policy URI on the consent list page. :pr:`102`
- Admin token deletion :pr:`100` :pr:`101`
- Revoked consents can be restored. :pr:`103`
- Pre-consented clients are displayed in the user consent list,
and their consents can be revoked. :issue:`69` :pr:`103`
Fixed
*****

View file

@ -1,3 +1,6 @@
import datetime
import uuid
from canaille.flaskutils import user_needed
from canaille.oidc.models import Client
from canaille.oidc.models import Consent
@ -5,7 +8,7 @@ from flask import Blueprint
from flask import flash
from flask import redirect
from flask import url_for
from flask_babel import _ as _
from flask_babel import gettext as _
from flask_themer import render_template
from .utils import SCOPE_DETAILS
@ -18,8 +21,14 @@ bp = Blueprint("consents", __name__, url_prefix="/consent")
@user_needed()
def consents(user):
consents = Consent.filter(subject=user.dn)
client_dns = list({t.client for t in consents})
client_dns = {t.client for t in consents}
clients = {dn: Client.get(dn) for dn in client_dns}
preconsented = [
client
for client in Client.filter()
if client.preconsent and client.dn not in clients
]
return render_template(
"oidc/user/consent_list.html",
consents=consents,
@ -27,6 +36,7 @@ def consents(user):
menuitem="consents",
scope_details=SCOPE_DETAILS,
ignored_scopes=["openid"],
preconsented=preconsented,
)
@ -61,6 +71,34 @@ def restore(user, consent_id):
else:
consent.restore()
if not consent.issue_date:
consent.issue_date = datetime.datetime.now()
consent.save()
flash(_("The access has been restored"), "success")
return redirect(url_for("oidc.consents.consents"))
@bp.route("/revoke-preconsent/<client_id>")
@user_needed()
def revoke_preconsent(user, client_id):
client = Client.get(client_id)
if not client or not client.preconsent:
flash(_("Could not revoke this access"), "error")
elif consent := Consent.get(client=client.dn, subject=user.dn):
return redirect(url_for("oidc.consents.revoke", consent_id=consent.cn[0]))
else:
consent = Consent(
cn=str(uuid.uuid4()),
client=client.dn,
subject=user.dn,
scope=client.scope,
)
consent.revoke()
consent.save()
flash(_("The access has been revoked"), "success")
return redirect(url_for("oidc.consents.consents"))

View file

@ -36,7 +36,9 @@
{% else %}
<div class="header">{{ client.client_name }}</div>
{% endif %}
<div class="meta">{% trans %}From:{% endtrans %} {{ consent.issue_date.strftime("%d/%m/%Y %H:%M:%S") }}</div>
{% if consent.issue_date %}
<div class="meta">{% trans %}From:{% endtrans %} {{ consent.issue_date.strftime("%d/%m/%Y %H:%M:%S") }}</div>
{% endif %}
{% if consent.revokation_date %}
<div class="meta">{% trans %}Revoked:{% endtrans %} {{ consent.revokation_date.strftime("%d/%m/%Y %H:%M:%S") }}</div>
{% endif %}
@ -103,5 +105,72 @@
</div>
</div>
{% endif %}
{% if preconsented %}
<h2 class="ui center aligned header">
<div class="content">
{{ _("Pre-authorized applications") }}
</div>
<div class="sub header">
{% trans %}Those applications automatically have authorizations to access you data.{% endtrans %}
</div>
</h2>
<div class="ui centered cards">
{% for client in preconsented %}
<div class="ui card">
<div class="content">
{% if client.logo_uri %}
<img class="right floated mini ui image" src="{{ client.logo_uri }}">
{% endif %}
{% if client.client_uri %}
<a href="{{ client.client_uri }}" class="header">{{ client.client_name }}</a>
{% else %}
<div class="header">{{ client.client_name }}</div>
{% endif %}
<div class="description">
<p>
{% trans %}Has access to:{% endtrans %}
</p>
<div class="ui list">
{% for scope in client.scope %}
{% if scope not in ignored_scopes %}
{% if scope not in scope_details %}
<div class="item" title="{{ scope }}">{{ scope }}</div>
{% else %}
<div class="item" title="{{ scope }}">
<i class="{{ scope_details[scope][0] }} icon"></i>
<div class="content">{{ scope_details[scope][1] }}</div>
</div>
{% endif %}
{% endif %}
{% endfor %}
</div>
</div>
</div>
{% if client.tos_uri or client.policy_uri %}
<div class="extra content">
{% if client.policy_uri %}
<span class="right floated">
<i class="mask icon"></i>
<a href="{{ client.policy_uri }}">{% trans %}Policy{% endtrans %}</a>
</span>
{% endif %}
{% if client.tos_uri %}
<span>
<i class="file signature icon"></i>
<a href="{{ client.tos_uri }}">{% trans %}Terms of service{% endtrans %}</a>
</span>
{% endif %}
</div>
{% endif %}
<a class="ui bottom attached button" href="{{ url_for('oidc.consents.revoke_preconsent', client_id=client.client_id ) }}">
<i class="remove icon"></i>
{% trans %}Revoke access{% endtrans %}
</a>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endblock %}

View file

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2023-02-14 18:43+0100\n"
"POT-Creation-Date: 2023-02-14 21:56+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -388,43 +388,43 @@ msgstr ""
msgid "The client has been deleted."
msgstr ""
#: canaille/oidc/consents.py:39
#: canaille/oidc/consents.py:49 canaille/oidc/consents.py:88
msgid "Could not revoke this access"
msgstr ""
#: canaille/oidc/consents.py:42
#: canaille/oidc/consents.py:52
msgid "The access is already revoked"
msgstr ""
#: canaille/oidc/consents.py:46
#: canaille/oidc/consents.py:56 canaille/oidc/consents.py:102
msgid "The access has been revoked"
msgstr ""
#: canaille/oidc/consents.py:57
#: canaille/oidc/consents.py:67
msgid "Could not restore this access"
msgstr ""
#: canaille/oidc/consents.py:60
#: canaille/oidc/consents.py:70
msgid "The access is not revoked"
msgstr ""
#: canaille/oidc/consents.py:64
#: canaille/oidc/consents.py:77
msgid "The access has been restored"
msgstr ""
#: canaille/oidc/endpoints.py:130
#: canaille/oidc/endpoints.py:131
msgid "You have been successfully logged out."
msgstr ""
#: canaille/oidc/endpoints.py:329
#: canaille/oidc/endpoints.py:332
msgid "You have been disconnected"
msgstr ""
#: canaille/oidc/endpoints.py:337
#: canaille/oidc/endpoints.py:340
msgid "An error happened during the logout"
msgstr ""
#: canaille/oidc/endpoints.py:349
#: canaille/oidc/endpoints.py:352
msgid "You have not been disconnected"
msgstr ""
@ -1216,42 +1216,54 @@ msgstr ""
msgid "Consult and revoke the authorization you gave to websites."
msgstr ""
#: canaille/templates/oidc/user/consent_list.html:39
#: canaille/templates/oidc/user/consent_list.html:40
msgid "From:"
msgstr ""
#: canaille/templates/oidc/user/consent_list.html:41
#: canaille/templates/oidc/user/consent_list.html:43
msgid "Revoked:"
msgstr ""
#: canaille/templates/oidc/user/consent_list.html:46
#: canaille/templates/oidc/user/consent_list.html:48
msgid "Had access to:"
msgstr ""
#: canaille/templates/oidc/user/consent_list.html:48
#: canaille/templates/oidc/user/consent_list.html:50
#: canaille/templates/oidc/user/consent_list.html:132
msgid "Has access to:"
msgstr ""
#: canaille/templates/oidc/user/consent_list.html:72
#: canaille/templates/oidc/user/consent_list.html:74
#: canaille/templates/oidc/user/consent_list.html:155
msgid "Policy"
msgstr ""
#: canaille/templates/oidc/user/consent_list.html:78
#: canaille/templates/oidc/user/consent_list.html:80
#: canaille/templates/oidc/user/consent_list.html:161
msgid "Terms of service"
msgstr ""
#: canaille/templates/oidc/user/consent_list.html:86
#: canaille/templates/oidc/user/consent_list.html:88
msgid "Restore access"
msgstr ""
#: canaille/templates/oidc/user/consent_list.html:91
#: canaille/templates/oidc/user/consent_list.html:93
#: canaille/templates/oidc/user/consent_list.html:168
msgid "Revoke access"
msgstr ""
#: canaille/templates/oidc/user/consent_list.html:101
#: canaille/templates/oidc/user/consent_list.html:103
msgid "You did not authorize applications yet."
msgstr ""
#: canaille/templates/oidc/user/consent_list.html:112
msgid "Pre-authorized applications"
msgstr ""
#: canaille/templates/oidc/user/consent_list.html:115
msgid "Those applications automatically have authorizations to access you data."
msgstr ""
#: canaille/templates/oidc/user/logout.html:9
#: canaille/themes/default/base.html:90
msgid "Log out"

View file

@ -36,7 +36,7 @@ Then you have access to:
- A canaille server at http://localhost:5000
- A dummy client at http://localhost:5001
- Another dummy client at http://localhost:5002
- Another dummy client at http://localhost:5002, for which consent is already granted for users
The canaille server has some default users:

View file

@ -45,3 +45,4 @@ oauthResponseType: code
oauthResponseType: id_token
oauthTokenEndpointAuthMethod: client_secret_basic
oauthAudience: oauthClientID=gn4yFN7GDykL7QP8v8gS9YfV,ou=clients,ou=oauth,dc=mydomain,dc=tld
oauthPreconsent: TRUE

View file

@ -142,3 +142,76 @@ def test_oidc_authorization_after_revokation(
token = Token.get(access_token=access_token)
assert token.client == client.dn
assert token.subject == logged_user.dn
def test_preconsented_client_appears_in_consent_list(testclient, client, logged_user):
assert not client.preconsent
res = testclient.get("/consent")
assert client.client_name not in res.text
client.preconsent = True
client.save()
res = testclient.get("/consent")
assert client.client_name in res.text
def test_revoke_preconsented_client(testclient, client, logged_user, token):
client.preconsent = True
client.save()
assert not Consent.get()
assert not token.revoked
res = testclient.get(f"/consent/revoke-preconsent/{client.client_id}", status=302)
assert ("success", "The access has been revoked") in res.flashes
consent = Consent.get()
assert consent.client == client.dn
assert consent.subject == logged_user.dn
assert consent.scope == ["openid", "email", "profile", "groups", "address", "phone"]
assert not consent.issue_date
token.reload()
assert token.revoked
res = testclient.get(f"/consent/restore/{consent.cn[0]}", status=302)
assert ("success", "The access has been restored") in res.flashes
consent.reload()
assert not consent.revoked
assert consent.issue_date
token.reload()
assert token.revoked
res = testclient.get(f"/consent/revoke/{consent.cn[0]}", status=302)
assert ("success", "The access has been revoked") in res.flashes
consent.reload()
assert consent.revoked
assert consent.issue_date
def test_revoke_invalid_preconsented_client(testclient, logged_user):
res = testclient.get("/consent/revoke-preconsent/invalid", status=302)
assert ("error", "Could not revoke this access") in res.flashes
def test_revoke_preconsented_client_with_manual_consent(
testclient, logged_user, client, consent
):
client.preconsent = True
client.save()
res = testclient.get(f"/consent/revoke-preconsent/{client.client_id}", status=302)
res = res.follow()
assert ("success", "The access has been revoked") in res.flashes
def test_revoke_preconsented_client_with_manual_revokation(
testclient, logged_user, client, consent
):
client.preconsent = True
client.save()
consent.revoke()
consent.save()
res = testclient.get(f"/consent/revoke-preconsent/{client.client_id}", status=302)
res = res.follow()
assert ("error", "The access is already revoked") in res.flashes