diff --git a/CHANGES.rst b/CHANGES.rst index ce1c61b7..31faa661 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -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 ***** diff --git a/canaille/oidc/consents.py b/canaille/oidc/consents.py index c44a9a4c..8734c52a 100644 --- a/canaille/oidc/consents.py +++ b/canaille/oidc/consents.py @@ -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/") +@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")) diff --git a/canaille/templates/oidc/user/consent_list.html b/canaille/templates/oidc/user/consent_list.html index 8b1aecc0..abfda217 100644 --- a/canaille/templates/oidc/user/consent_list.html +++ b/canaille/templates/oidc/user/consent_list.html @@ -36,7 +36,9 @@ {% else %}
{{ client.client_name }}
{% endif %} -
{% trans %}From:{% endtrans %} {{ consent.issue_date.strftime("%d/%m/%Y %H:%M:%S") }}
+ {% if consent.issue_date %} +
{% trans %}From:{% endtrans %} {{ consent.issue_date.strftime("%d/%m/%Y %H:%M:%S") }}
+ {% endif %} {% if consent.revokation_date %}
{% trans %}Revoked:{% endtrans %} {{ consent.revokation_date.strftime("%d/%m/%Y %H:%M:%S") }}
{% endif %} @@ -103,5 +105,72 @@ {% endif %} + + {% if preconsented %} +

+
+ {{ _("Pre-authorized applications") }} +
+
+ {% trans %}Those applications automatically have authorizations to access you data.{% endtrans %} +
+

+
+ {% for client in preconsented %} +
+
+ {% if client.logo_uri %} + + {% endif %} + {% if client.client_uri %} + {{ client.client_name }} + {% else %} +
{{ client.client_name }}
+ {% endif %} +
+

+ {% trans %}Has access to:{% endtrans %} +

+
+ {% for scope in client.scope %} + {% if scope not in ignored_scopes %} + {% if scope not in scope_details %} +
{{ scope }}
+ {% else %} +
+ +
{{ scope_details[scope][1] }}
+
+ {% endif %} + {% endif %} + {% endfor %} +
+
+
+ {% if client.tos_uri or client.policy_uri %} +
+ {% if client.policy_uri %} + + + {% trans %}Policy{% endtrans %} + + {% endif %} + {% if client.tos_uri %} + + + {% trans %}Terms of service{% endtrans %} + + {% endif %} +
+ {% endif %} + + + {% trans %}Revoke access{% endtrans %} + +
+ {% endfor %} +
+ + {% endif %} {% endblock %} diff --git a/canaille/translations/messages.pot b/canaille/translations/messages.pot index cbb68a30..ba6fbb22 100644 --- a/canaille/translations/messages.pot +++ b/canaille/translations/messages.pot @@ -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 \n" "Language-Team: LANGUAGE \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" diff --git a/demo/README.md b/demo/README.md index 58b5bd5e..539c3c00 100644 --- a/demo/README.md +++ b/demo/README.md @@ -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: diff --git a/demo/ldif/bootstrap-oidc.ldif b/demo/ldif/bootstrap-oidc.ldif index e3f50649..321f9153 100644 --- a/demo/ldif/bootstrap-oidc.ldif +++ b/demo/ldif/bootstrap-oidc.ldif @@ -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 diff --git a/tests/oidc/test_consent.py b/tests/oidc/test_consent.py index 886cc495..65152fc1 100644 --- a/tests/oidc/test_consent.py +++ b/tests/oidc/test_consent.py @@ -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