diff --git a/CHANGES.rst b/CHANGES.rst index b4d8d81c..31faa661 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,6 +8,9 @@ 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 3981a5bd..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 gettext +from flask_babel import gettext as _ from flask_themer import render_template from .utils import SCOPE_DETAILS @@ -18,9 +21,14 @@ bp = Blueprint("consents", __name__, url_prefix="/consent") @user_needed() def consents(user): consents = Consent.filter(subject=user.dn) - consents = [c for c in consents if not c.revokation_date] - 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, @@ -28,19 +36,69 @@ def consents(user): menuitem="consents", scope_details=SCOPE_DETAILS, ignored_scopes=["openid"], + preconsented=preconsented, ) -@bp.route("/delete/") +@bp.route("/revoke/") @user_needed() -def delete(user, consent_id): +def revoke(user, consent_id): consent = Consent.get(consent_id) if not consent or consent.subject != user.dn: - flash(gettext("Could not delete this access"), "error") + flash(_("Could not revoke this access"), "error") + + elif consent.revokation_date: + flash(_("The access is already revoked"), "error") else: consent.revoke() - flash(gettext("The access has been revoked"), "success") + flash(_("The access has been revoked"), "success") + + return redirect(url_for("oidc.consents.consents")) + + +@bp.route("/restore/") +@user_needed() +def restore(user, consent_id): + consent = Consent.get(consent_id) + + if not consent or consent.subject != user.dn: + flash(_("Could not restore this access"), "error") + + elif not consent.revokation_date: + flash(_("The access is not revoked"), "error") + + 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/oidc/endpoints.py b/canaille/oidc/endpoints.py index 395ac612..650e2e4c 100644 --- a/canaille/oidc/endpoints.py +++ b/canaille/oidc/endpoints.py @@ -93,12 +93,13 @@ def authorize(): client=client.dn, subject=user.dn, ) - consents = [c for c in consents if not c.revokation_date] consent = consents[0] if consents else None if request.method == "GET": - if client.preconsent or ( - consent and all(scope in set(consent.scope) for scope in scopes) + if ( + (client.preconsent and (not consent or not consent.revoked)) + or (consent and all(scope in set(consent.scope) for scope in scopes)) + and not consent.revoked ): return authorization.create_authorization_response(grant_user=user.dn) @@ -137,6 +138,8 @@ def authorize(): grant_user = user.dn if consent: + if consent.revoked: + consent.restore() consent.scope = client.get_allowed_scope( list(set(scopes + consents[0].scope)) ).split(" ") diff --git a/canaille/oidc/models.py b/canaille/oidc/models.py index 50a7a310..841006a2 100644 --- a/canaille/oidc/models.py +++ b/canaille/oidc/models.py @@ -222,6 +222,10 @@ class Consent(LDAPObject): "revokation_date": "oauthRevokationDate", } + @property + def revoked(self): + return bool(self.revokation_date) + def revoke(self): self.revokation_date = datetime.datetime.now() self.save() @@ -230,10 +234,11 @@ class Consent(LDAPObject): oauthClient=self.client, oauthSubject=self.subject, ) + tokens = [token for token in tokens if not token.revoked] for t in tokens: - different_scope = any(scope not in t.scope[0] for scope in self.scope) - if t.revoked or different_scope: - continue - t.revokation_date = self.revokation_date t.save() + + def restore(self): + self.revokation_date = None + self.save() diff --git a/canaille/templates/oidc/user/consent_list.html b/canaille/templates/oidc/user/consent_list.html index a628c46b..abfda217 100644 --- a/canaille/templates/oidc/user/consent_list.html +++ b/canaille/templates/oidc/user/consent_list.html @@ -36,12 +36,20 @@ {% 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 %}
-

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

+

+ {% if consent.revokation_date %} + {% trans %}Had access to:{% endtrans %} + {% else %} + {% trans %}Has access to:{% endtrans %} + {% endif %} +

{% for scope in consent.scope %} {% if scope not in ignored_scopes %} @@ -74,10 +82,17 @@ {% endif %}
{% endif %} - - - {% trans %}Remove access{% endtrans %} - + {% if consent.revokation_date %} + + + {% trans %}Restore access{% endtrans %} + + {% else %} + + + {% trans %}Revoke access{% endtrans %} + + {% endif %}
{% endfor %} @@ -90,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 f0911148..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-10 09:52+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,27 +388,43 @@ msgstr "" msgid "The client has been deleted." msgstr "" -#: canaille/oidc/consents.py:40 -msgid "Could not delete this access" +#: canaille/oidc/consents.py:49 canaille/oidc/consents.py:88 +msgid "Could not revoke this access" msgstr "" -#: canaille/oidc/consents.py:44 +#: canaille/oidc/consents.py:52 +msgid "The access is already revoked" +msgstr "" + +#: canaille/oidc/consents.py:56 canaille/oidc/consents.py:102 msgid "The access has been revoked" msgstr "" -#: canaille/oidc/endpoints.py:130 +#: canaille/oidc/consents.py:67 +msgid "Could not restore this access" +msgstr "" + +#: canaille/oidc/consents.py:70 +msgid "The access is not revoked" +msgstr "" + +#: canaille/oidc/consents.py:77 +msgid "The access has been restored" +msgstr "" + +#: 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 "" @@ -1200,26 +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:44 +#: canaille/templates/oidc/user/consent_list.html:48 +msgid "Had access to:" +msgstr "" + +#: 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:63 -msgid "Remove access" +#: 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:72 +#: 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:88 +msgid "Restore access" +msgstr "" + +#: 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: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 3785ab6a..65152fc1 100644 --- a/tests/oidc/test_consent.py +++ b/tests/oidc/test_consent.py @@ -1,52 +1,217 @@ -import datetime +from urllib.parse import parse_qs +from urllib.parse import urlsplit + +from canaille.oidc.models import Consent +from canaille.oidc.models import Token + +from . import client_credentials def test_no_logged_no_access(testclient): testclient.get("/consent", status=403) -def test_delete(testclient, client, consent, logged_user, token): +def test_revokation(testclient, client, consent, logged_user, token): res = testclient.get("/consent", status=200) assert client.client_name in res.text + assert "Revoke access" in res.text + assert "Restore access" not in res.text + assert not consent.revoked assert not token.revoked - res = testclient.get(f"/consent/delete/{consent.cn[0]}", status=302) + res = testclient.get(f"/consent/revoke/{consent.cn[0]}", status=302) assert ("success", "The access has been revoked") in res.flashes res = res.follow(status=200) - assert client.client_name not in res.text + assert "Revoke access" not in res.text + assert "Restore access" in res.text + consent.reload() + assert consent.revoked token.reload() assert token.revoked -def test_delete_token_already_revoked(testclient, client, consent, logged_user, token): - revokation_date = datetime.datetime.utcnow().replace( - microsecond=0 - ) - datetime.timedelta(days=7) - token.revokation_date = revokation_date - token.save() +def test_revokation_already_revoked(testclient, client, consent, logged_user): + consent.revoke() - token.reload() - assert token.revoked - assert token.revokation_date == revokation_date + consent.reload() + assert consent.revoked - res = testclient.get(f"/consent/delete/{consent.cn[0]}", status=302) - assert ("success", "The access has been revoked") in res.flashes + res = testclient.get(f"/consent/revoke/{consent.cn[0]}", status=302) + assert ("error", "The access is already revoked") in res.flashes res = res.follow(status=200) - assert client.client_name not in res.text + consent.reload() + assert consent.revoked + + +def test_restoration(testclient, client, consent, logged_user, token): + consent.revoke() + + consent.reload() + assert consent.revoked + 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 + res = res.follow(status=200) + + consent.reload() + assert not consent.revoked token.reload() assert token.revoked - assert token.revokation_date == revokation_date -def test_invalid_consent_delete(testclient, client, logged_user): - res = testclient.get(f"/consent/delete/invalid", status=302) +def test_restoration_already_restored(testclient, client, consent, logged_user, token): + assert not consent.revoked + + res = testclient.get(f"/consent/restore/{consent.cn[0]}", status=302) + assert ("error", "The access is not revoked") in res.flashes + res = res.follow(status=200) + + +def test_invalid_consent_revokation(testclient, client, logged_user): + res = testclient.get(f"/consent/revoke/invalid", status=302) assert ("success", "The access has been revoked") not in res.flashes - assert ("error", "Could not delete this access") in res.flashes + assert ("error", "Could not revoke this access") in res.flashes -def test_someone_else_consent_delete(testclient, client, consent, logged_moderator): - res = testclient.get(f"/consent/delete/{consent.cn[0]}", status=302) +def test_someone_else_consent_revokation(testclient, client, consent, logged_moderator): + res = testclient.get(f"/consent/revoke/{consent.cn[0]}", status=302) assert ("success", "The access has been revoked") not in res.flashes - assert ("error", "Could not delete this access") in res.flashes + assert ("error", "Could not revoke this access") in res.flashes + + +def test_invalid_consent_restoration(testclient, client, logged_user): + res = testclient.get(f"/consent/restore/invalid", status=302) + assert ("success", "The access has been restored") not in res.flashes + assert ("error", "Could not restore this access") in res.flashes + + +def test_someone_else_consent_restoration( + testclient, client, consent, logged_moderator +): + res = testclient.get(f"/consent/restore/{consent.cn[0]}", status=302) + assert ("success", "The access has been restore") not in res.flashes + assert ("error", "Could not restore this access") in res.flashes + + +def test_oidc_authorization_after_revokation( + testclient, logged_user, client, keypair, consent +): + consent.revoke() + + consent.reload() + assert consent.revoked + + res = testclient.get( + "/oauth/authorize", + params=dict( + response_type="code", + client_id=client.client_id, + scope="openid profile", + nonce="somenonce", + ), + status=200, + ) + + res = res.form.submit(name="answer", value="accept", status=302) + + Consent.all() + consents = Consent.filter(client=client.dn, subject=logged_user.dn) + assert consents[0].dn == consent.dn + consent.reload() + assert not consent.revoked + + params = parse_qs(urlsplit(res.location).query) + code = params["code"][0] + res = testclient.post( + "/oauth/token", + params=dict( + grant_type="authorization_code", + code=code, + scope="openid profile", + redirect_uri=client.redirect_uris[0], + ), + headers={"Authorization": f"Basic {client_credentials(client)}"}, + status=200, + ) + + access_token = res.json["access_token"] + 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