Implemented RP-initiated logout

This commit is contained in:
Éloi Rivard 2022-05-20 14:07:56 +02:00
parent b378d27b23
commit 95ec09fe54
15 changed files with 743 additions and 76 deletions

View file

@ -11,6 +11,7 @@ Added
- ``DISABLE_PASSWORD_RESET`` configuration option to disable password recovery. :pr:`46` - ``DISABLE_PASSWORD_RESET`` configuration option to disable password recovery. :pr:`46`
- ``edit_self`` ACL permission to control user self edition. :pr:`47` - ``edit_self`` ACL permission to control user self edition. :pr:`47`
- Implemented RP-initiated logout :pr:`54`
Changed Changed
******* *******

View file

@ -327,6 +327,14 @@ olcAttributeTypes: ( 1.3.6.1.4.1.56207.1.1.38 NAME 'oauthAuthorizationCodeID'
SINGLE-VALUE SINGLE-VALUE
USAGE userApplications USAGE userApplications
X-ORIGIN 'OAuth 2.0' ) X-ORIGIN 'OAuth 2.0' )
olcAttributeTypes: ( 1.3.6.1.4.1.56207.1.1.39 NAME 'oauthPostLogoutRedirectURI'
DESC 'OAuth 2.0 Post logout redirection URI'
EQUALITY caseIgnoreMatch
ORDERING caseIgnoreOrderingMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
olcObjectClasses: ( 1.3.6.1.4.1.56207.1.2.1 NAME 'oauthClient' olcObjectClasses: ( 1.3.6.1.4.1.56207.1.2.1 NAME 'oauthClient'
DESC 'OAuth 2.0 Authorization Code' DESC 'OAuth 2.0 Authorization Code'
SUP top SUP top
@ -352,7 +360,8 @@ olcObjectClasses: ( 1.3.6.1.4.1.56207.1.2.1 NAME 'oauthClient'
oauthSoftwareID $ oauthSoftwareID $
oauthSoftwareVersion $ oauthSoftwareVersion $
oauthAudience $ oauthAudience $
oauthPreconsent ) oauthPreconsent $
oauthPostLogoutRedirectURI )
) )
X-ORIGIN 'OAuth 2.0' ) X-ORIGIN 'OAuth 2.0' )
olcObjectClasses: ( 1.3.6.1.4.1.56207.1.2.2 NAME 'oauthAuthorizationCode' olcObjectClasses: ( 1.3.6.1.4.1.56207.1.2.2 NAME 'oauthAuthorizationCode'

View file

@ -324,6 +324,15 @@ attributetypes ( 1.3.6.1.4.1.56207.1.1.38 NAME 'oauthAuthorizationCodeID'
SINGLE-VALUE SINGLE-VALUE
USAGE userApplications USAGE userApplications
X-ORIGIN 'OAuth 2.0' ) X-ORIGIN 'OAuth 2.0' )
attributetypes ( 1.3.6.1.4.1.56207.1.1.39 NAME 'oauthPostLogoutRedirectURI'
DESC 'OAuth 2.0 Post logout redirection URI'
EQUALITY caseExactMatch
ORDERING caseExactOrderingMatch
SUBSTR caseExactSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
SINGLE-VALUE
USAGE userApplications
X-ORIGIN 'OAuth 2.0' )
objectclass ( 1.3.6.1.4.1.56207.1.2.1 NAME 'oauthClient' objectclass ( 1.3.6.1.4.1.56207.1.2.1 NAME 'oauthClient'
DESC 'OAuth 2.0 Authorization Code' DESC 'OAuth 2.0 Authorization Code'
SUP top SUP top
@ -349,7 +358,8 @@ objectclass ( 1.3.6.1.4.1.56207.1.2.1 NAME 'oauthClient'
oauthSoftwareID $ oauthSoftwareID $
oauthSoftwareVersion $ oauthSoftwareVersion $
oauthAudience $ oauthAudience $
oauthPreconsent ) oauthPreconsent $
oauthPostLogoutRedirectURI )
) )
X-ORIGIN 'OAuth 2.0' ) X-ORIGIN 'OAuth 2.0' )
objectclass ( 1.3.6.1.4.1.56207.1.2.2 NAME 'oauthAuthorizationCode' objectclass ( 1.3.6.1.4.1.56207.1.2.2 NAME 'oauthAuthorizationCode'

View file

@ -52,6 +52,11 @@ class ClientAdd(FlaskForm):
validators=[wtforms.validators.DataRequired()], validators=[wtforms.validators.DataRequired()],
render_kw={"placeholder": "https://mydomain.tld/callback"}, render_kw={"placeholder": "https://mydomain.tld/callback"},
) )
post_logout_redirect_uris = wtforms.URLField(
_("Post logout redirect URIs"),
validators=[wtforms.validators.Optional()],
render_kw={"placeholder": "https://mydomain.tld/you-have-been-disconnected"},
)
grant_type = wtforms.SelectMultipleField( grant_type = wtforms.SelectMultipleField(
_("Grant types"), _("Grant types"),
validators=[wtforms.validators.DataRequired()], validators=[wtforms.validators.DataRequired()],
@ -163,6 +168,7 @@ def add(user):
uri=form["uri"].data, uri=form["uri"].data,
grant_type=form["grant_type"].data, grant_type=form["grant_type"].data,
redirect_uris=[form["redirect_uris"].data], redirect_uris=[form["redirect_uris"].data],
post_logout_redirect_uris=[form["post_logout_redirect_uris"].data],
response_type=form["response_type"].data, response_type=form["response_type"].data,
scope=form["scope"].data.split(" "), scope=form["scope"].data.split(" "),
token_endpoint_auth_method=form["token_endpoint_auth_method"].data, token_endpoint_auth_method=form["token_endpoint_auth_method"].data,
@ -209,6 +215,11 @@ def client_edit(client_id):
data = dict(client) data = dict(client)
data["scope"] = " ".join(data["scope"]) data["scope"] = " ".join(data["scope"])
data["redirect_uris"] = data["redirect_uris"][0] data["redirect_uris"] = data["redirect_uris"][0]
data["post_logout_redirect_uris"] = (
data["post_logout_redirect_uris"][0]
if data["post_logout_redirect_uris"]
else ""
)
data["preconsent"] = client.preconsent data["preconsent"] = client.preconsent
form = ClientAdd(request.form or None, data=data, client=client) form = ClientAdd(request.form or None, data=data, client=client)
@ -230,6 +241,7 @@ def client_edit(client_id):
uri=form["uri"].data, uri=form["uri"].data,
grant_type=form["grant_type"].data, grant_type=form["grant_type"].data,
redirect_uris=[form["redirect_uris"].data], redirect_uris=[form["redirect_uris"].data],
post_logout_redirect_uris=[form["post_logout_redirect_uris"].data],
response_type=form["response_type"].data, response_type=form["response_type"].data,
scope=form["scope"].data.split(" "), scope=form["scope"].data.split(" "),
token_endpoint_auth_method=form["token_endpoint_auth_method"].data, token_endpoint_auth_method=form["token_endpoint_auth_method"].data,

6
canaille/oidc/forms.py Normal file
View file

@ -0,0 +1,6 @@
import wtforms
from flask_wtf import FlaskForm
class LogoutForm(FlaskForm):
answer = wtforms.SubmitField()

View file

@ -19,6 +19,7 @@ class Client(LDAPObject, ClientMixin):
"contact": "oauthClientContact", "contact": "oauthClientContact",
"uri": "oauthClientURI", "uri": "oauthClientURI",
"redirect_uris": "oauthRedirectURIs", "redirect_uris": "oauthRedirectURIs",
"post_logout_redirect_uris": "oauthPostLogoutRedirectURI",
"logo_uri": "oauthLogoURI", "logo_uri": "oauthLogoURI",
"issue_date": "oauthIssueDate", "issue_date": "oauthIssueDate",
"secret": "oauthClientSecret", "secret": "oauthClientSecret",

View file

@ -1,7 +1,10 @@
import datetime import datetime
from urllib.parse import urlsplit
from urllib.parse import urlunsplit
from authlib.integrations.flask_oauth2 import current_token from authlib.integrations.flask_oauth2 import current_token
from authlib.jose import jwk from authlib.jose import jwk
from authlib.jose import jwt
from authlib.oauth2 import OAuth2Error from authlib.oauth2 import OAuth2Error
from flask import abort from flask import abort
from flask import Blueprint from flask import Blueprint
@ -11,13 +14,16 @@ from flask import jsonify
from flask import redirect from flask import redirect
from flask import request from flask import request
from flask import session from flask import session
from flask import url_for
from flask_babel import gettext from flask_babel import gettext
from flask_babel import lazy_gettext as _ from flask_babel import lazy_gettext as _
from flask_themer import render_template from flask_themer import render_template
from werkzeug.datastructures import CombinedMultiDict
from ..flaskutils import current_user from ..flaskutils import current_user
from ..forms import FullLoginForm from ..forms import FullLoginForm
from ..models import User from ..models import User
from .forms import LogoutForm
from .models import Client from .models import Client
from .models import Consent from .models import Consent
from .oauth2utils import authorization from .oauth2utils import authorization
@ -43,6 +49,11 @@ CLAIMS = {
} }
def get_public_key():
with open(current_app.config["JWT"]["PUBLIC_KEY"]) as fd:
return fd.read()
@bp.route("/authorize", methods=["GET", "POST"]) @bp.route("/authorize", methods=["GET", "POST"])
def authorize(): def authorize():
current_app.logger.debug( current_app.logger.debug(
@ -184,10 +195,9 @@ def revoke_token():
@bp.route("/jwks.json") @bp.route("/jwks.json")
def jwks(): def jwks():
with open(current_app.config["JWT"]["PUBLIC_KEY"]) as fd: obj = jwk.dumps(
pubkey = fd.read() get_public_key(), current_app.config["JWT"].get("KTY", DEFAULT_JWT_KTY)
)
obj = jwk.dumps(pubkey, current_app.config["JWT"].get("KTY", DEFAULT_JWT_KTY))
return jsonify( return jsonify(
{ {
"keys": [ "keys": [
@ -209,3 +219,121 @@ def userinfo():
response = generate_user_info(current_token.subject, current_token.scope[0]) response = generate_user_info(current_token.subject, current_token.scope[0])
current_app.logger.debug("userinfo endpoint response: %s", response) current_app.logger.debug("userinfo endpoint response: %s", response)
return jsonify(response) return jsonify(response)
def set_parameter_in_url_query(url, **kwargs):
split = list(urlsplit(url))
parameters = "&".join(f"{key}={value}" for key, value in kwargs.items())
if split[3]:
split[3] = f"{split[3]}&{parameters}"
else:
split[3] = parameters
return urlunsplit(split)
@bp.route("/end_session", methods=["GET", "POST"])
def end_session():
data = CombinedMultiDict((request.args, request.form))
user = current_user()
form = LogoutForm(request.form)
form.action = url_for("oidc.oauth.end_session_submit")
client = None
valid_uris = []
if "client_id" in data:
client = Client.get(data["client_id"])
if client:
valid_uris = client.post_logout_redirect_uris
if (
not data.get("id_token_hint")
or (data.get("logout_hint") and data["logout_hint"] != user.uid[0])
) and not session.get("end_session_confirmation"):
session["end_session_data"] = data
return render_template(
"oidc/user/logout.html", form=form, client=client, menu=False
)
if data.get("id_token_hint"):
id_token = jwt.decode(data["id_token_hint"], get_public_key())
if not id_token["iss"] == current_app.config["JWT"]["ISS"]:
return jsonify(
{
"status": "error",
"message": "id_token_hint has not been issued here",
}
)
if "client_id" in data:
if (
data["client_id"] != id_token["aud"]
and data["client_id"] not in id_token["aud"]
):
return jsonify(
{
"status": "error",
"message": "id_token_hint and client_id don't match",
}
)
else:
client_ids = (
id_token["aud"]
if isinstance(id_token["aud"], list)
else [id_token["aud"]]
)
for client_id in client_ids:
client = Client.get(client_id)
if client:
valid_uris.extend(client.post_logout_redirect_uris)
if user.uid[0] != id_token["sub"] and not session.get(
"end_session_confirmation"
):
session["end_session_data"] = data
return render_template(
"oidc/user/logout.html", form=form, client=client, menu=False
)
user.logout()
if "end_session_confirmation" in session:
del session["end_session_confirmation"]
if (
"post_logout_redirect_uri" in data
and data["post_logout_redirect_uri"] in valid_uris
):
url = data["post_logout_redirect_uri"]
if "state" in data:
url = set_parameter_in_url_query(url, state=data["state"])
return redirect(data["post_logout_redirect_uri"])
flash(_("You have been disconnected"), "success")
return redirect(url_for("account.index"))
@bp.route("/end_session_confirm", methods=["POST"])
def end_session_submit():
form = LogoutForm(request.form)
if not form.validate():
flash(_("An error happened during the logout"), "error")
client = Client.get(session.get("end_session_data", {}).get("client_id"))
return render_template("oidc/user/logout.html", form=form, client=client)
data = session["end_session_data"]
del session["end_session_data"]
if request.form["answer"] == "logout":
session["end_session_confirmation"] = True
url = set_parameter_in_url_query(url_for("oidc.oauth.end_session"), **data)
return redirect(url)
flash(_("You have not been disconnected"), "info")
return redirect(url_for("account.index"))

View file

@ -0,0 +1,42 @@
{% extends theme('base.html') %}
{% import 'fomanticui.html' as sui %}
{% block content %}
<div class="ui segment">
<div class="ui center aligned">
<h2 class="ui center aligned icon header">
<i class="sign out icon"></i>
<div class="content">{% trans %}Log out{% endtrans %}</div>
<div class="sub header">{% trans %}Do you want to log out?{% endtrans %}</div>
</h2>
<div class="ui message">
<p>
{{ _("You are currently logged in as %(username)s.", username=user.name) }}
{% if client %}
{{ _("The application %(client_name)s want to disconnect your account.", client_name=client.name) }}
{% endif %}
</p>
</div>
</div>
<br>
<div class="ui center aligned container">
<form method="POST"
id="{{ form.id or form.__class__.__name__|lower }}"
action="{{ form.action }}"
role="form"
class="ui form"
>
{{ form.hidden_tag() if form.hidden_tag }}
<div class="ui stackable buttons">
<button name="answer" type="submit" class="ui button" value="stay" id="stay">
{% trans %}Stay logged{% endtrans %}
</button>
<button name="answer" type="submit" class="ui primary button" value="logout" id="logout">
{% trans %}Logout{% endtrans %}
</button>
</div>
</form>
</div>
</div>
{% endblock %}

View file

@ -3,25 +3,25 @@
# This file is distributed under the same license as the PROJECT project. # This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2020. # FIRST AUTHOR <EMAIL@ADDRESS>, 2020.
# Camille <camille@yaal.coop>, 2021-2022. # Camille <camille@yaal.coop>, 2021-2022.
# Éloi Rivard <eloi@yaal.fr>, 2020-2022. # Éloi Rivard <eloi.rivard@aquilenet.fr>, 2020-2022.
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: contact@yaal.fr\n" "Report-Msgid-Bugs-To: contact@yaal.fr\n"
"POT-Creation-Date: 2022-04-06 17:43+0200\n" "POT-Creation-Date: 2022-06-02 15:50+0200\n"
"PO-Revision-Date: 2022-04-06 17:44+0200\n" "PO-Revision-Date: 2022-06-02 15:55+0200\n"
"Last-Translator: Éloi Rivard <eloi@yaal.fr>\n" "Last-Translator: Éloi Rivard <eloi.rivard@aquilenet.fr>\n"
"Language: fr_FR\n" "Language: fr\n"
"Language-Team: French - France <equipe@yaal.fr>\n" "Language-Team: French <traduc@traduc.org>\n"
"Plural-Forms: nplurals=2; plural=(n != 1)\n" "Plural-Forms: nplurals=2; plural=(n > 1)\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.9.1\n" "Generated-By: Babel 2.10.1\n"
"X-Generator: Gtranslator 40.0\n" "X-Generator: Gtranslator 40.0\n"
#: canaille/account.py:93 canaille/account.py:118 canaille/oidc/oauth.py:77 #: canaille/account.py:93 canaille/account.py:118 canaille/oidc/oauth.py:88
msgid "Login failed, please check your information" msgid "Login failed, please check your information"
msgstr "La connexion a échoué, veuillez vérifier vos informations." msgstr "La connexion a échoué, veuillez vérifier vos informations."
@ -338,11 +338,11 @@ msgstr "Le groupe %(group)s a bien été supprimé"
msgid "You have been invited to create an account on {website_name}" msgid "You have been invited to create an account on {website_name}"
msgstr "Vous êtes invités à vous créer un compte sur {website_name}" msgstr "Vous êtes invités à vous créer un compte sur {website_name}"
#: canaille/ldap_backend/backend.py:54 #: canaille/ldap_backend/backend.py:50
msgid "Could not connect to the LDAP server '{uri}'" msgid "Could not connect to the LDAP server '{uri}'"
msgstr "Impossible de se connecter au serveur LDAP '{uri}'" msgstr "Impossible de se connecter au serveur LDAP '{uri}'"
#: canaille/ldap_backend/backend.py:70 #: canaille/ldap_backend/backend.py:66
msgid "LDAP authentication failed with user '{user}'" msgid "LDAP authentication failed with user '{user}'"
msgstr "" msgstr ""
"L'authentification au serveur LDAP a échoué pour l'utilisateur '{user}'" "L'authentification au serveur LDAP a échoué pour l'utilisateur '{user}'"
@ -360,74 +360,78 @@ msgid "Redirect URIs"
msgstr "URIs de redirection" msgstr "URIs de redirection"
#: canaille/oidc/clients.py:56 #: canaille/oidc/clients.py:56
msgid "Post logout redirect URIs"
msgstr "URIs de redirection post-déconnexion"
#: canaille/oidc/clients.py:61
msgid "Grant types" msgid "Grant types"
msgstr "Grant types" msgstr "Grant types"
#: canaille/oidc/clients.py:68 canaille/templates/oidc/admin/token_view.html:33 #: canaille/oidc/clients.py:73 canaille/templates/oidc/admin/token_view.html:33
msgid "Scope" msgid "Scope"
msgstr "Scope" msgstr "Scope"
#: canaille/oidc/clients.py:74 #: canaille/oidc/clients.py:79
msgid "Response types" msgid "Response types"
msgstr "Types de réponse" msgstr "Types de réponse"
#: canaille/oidc/clients.py:80 #: canaille/oidc/clients.py:85
msgid "Token Endpoint Auth Method" msgid "Token Endpoint Auth Method"
msgstr "Token Endpoint Auth Method" msgstr "Token Endpoint Auth Method"
#: canaille/oidc/clients.py:90 #: canaille/oidc/clients.py:95
msgid "Token audiences" msgid "Token audiences"
msgstr "Token audiences" msgstr "Token audiences"
#: canaille/oidc/clients.py:96 #: canaille/oidc/clients.py:101
msgid "Logo URI" msgid "Logo URI"
msgstr "URI du logo" msgstr "URI du logo"
#: canaille/oidc/clients.py:101 #: canaille/oidc/clients.py:106
msgid "Terms of service URI" msgid "Terms of service URI"
msgstr "URI des conditions d'utilisation" msgstr "URI des conditions d'utilisation"
#: canaille/oidc/clients.py:106 #: canaille/oidc/clients.py:111
msgid "Policy URI" msgid "Policy URI"
msgstr "URI de la politique de confidentialité" msgstr "URI de la politique de confidentialité"
#: canaille/oidc/clients.py:111 #: canaille/oidc/clients.py:116
msgid "Software ID" msgid "Software ID"
msgstr "ID du logiciel" msgstr "ID du logiciel"
#: canaille/oidc/clients.py:116 #: canaille/oidc/clients.py:121
msgid "Software Version" msgid "Software Version"
msgstr "Version du logiciel" msgstr "Version du logiciel"
#: canaille/oidc/clients.py:121 #: canaille/oidc/clients.py:126
msgid "JWK" msgid "JWK"
msgstr "JWK" msgstr "JWK"
#: canaille/oidc/clients.py:126 #: canaille/oidc/clients.py:131
msgid "JKW URI" msgid "JKW URI"
msgstr "URI du JWK" msgstr "URI du JWK"
#: canaille/oidc/clients.py:131 #: canaille/oidc/clients.py:136
msgid "Pre-consent" msgid "Pre-consent"
msgstr "Pré-autorisé" msgstr "Pré-autorisé"
#: canaille/oidc/clients.py:149 #: canaille/oidc/clients.py:154
msgid "The client has not been added. Please check your information." msgid "The client has not been added. Please check your information."
msgstr "Le client n'a pas été ajouté. Veuillez vérifier vos informations." msgstr "Le client n'a pas été ajouté. Veuillez vérifier vos informations."
#: canaille/oidc/clients.py:184 #: canaille/oidc/clients.py:190
msgid "The client has been created." msgid "The client has been created."
msgstr "Le client a été créé." msgstr "Le client a été créé."
#: canaille/oidc/clients.py:222 #: canaille/oidc/clients.py:233
msgid "The client has not been edited. Please check your information." msgid "The client has not been edited. Please check your information."
msgstr "Le client n'a pas été édité. Veuillez vérifier vos informations." msgstr "Le client n'a pas été édité. Veuillez vérifier vos informations."
#: canaille/oidc/clients.py:248 #: canaille/oidc/clients.py:260
msgid "The client has been edited." msgid "The client has been edited."
msgstr "Le client a été édité." msgstr "Le client a été édité."
#: canaille/oidc/clients.py:264 #: canaille/oidc/clients.py:276
msgid "The client has been deleted." msgid "The client has been deleted."
msgstr "Le client a été supprimé." msgstr "Le client a été supprimé."
@ -439,30 +443,43 @@ msgstr "Impossible de supprimer cet accès."
msgid "The access has been revoked" msgid "The access has been revoked"
msgstr "L'accès a été révoqué." msgstr "L'accès a été révoqué."
#: canaille/oidc/oauth.py:37 #: canaille/oidc/oauth.py:43
msgid "Personnal information about yourself, such as your name or your gender." msgid "Personnal information about yourself, such as your name or your gender."
msgstr "Vos informations personnelles, comme votre nom ou votre genre." msgstr "Vos informations personnelles, comme votre nom ou votre genre."
#: canaille/oidc/oauth.py:39 #: canaille/oidc/oauth.py:45
msgid "Your email address." msgid "Your email address."
msgstr "Votre adresse email." msgstr "Votre adresse email."
#: canaille/oidc/oauth.py:40 #: canaille/oidc/oauth.py:46
msgid "Your postal address." msgid "Your postal address."
msgstr "Votre adresse postale." msgstr "Votre adresse postale."
#: canaille/oidc/oauth.py:41 #: canaille/oidc/oauth.py:47
msgid "Your phone number." msgid "Your phone number."
msgstr "Votre numéro de téléphone." msgstr "Votre numéro de téléphone."
#: canaille/oidc/oauth.py:42 #: canaille/oidc/oauth.py:48
msgid "Groups you are belonging to" msgid "Groups you are belonging to"
msgstr "Les groupes auxquels vous appartenez" msgstr "Les groupes auxquels vous appartenez"
#: canaille/oidc/oauth.py:125 #: canaille/oidc/oauth.py:136
msgid "You have been successfully logged out." msgid "You have been successfully logged out."
msgstr "Vous avez été déconnecté·e." msgstr "Vous avez été déconnecté·e."
#: canaille/oidc/oauth.py:317
#, python-format
msgid "You have been disconnected"
msgstr "Vous avez été déconnecté·e."
#: canaille/oidc/oauth.py:325
msgid "An error happened during the logout"
msgstr "Une erreur est survenue lors de la déconnexion"
#: canaille/oidc/oauth.py:337
msgid "You have not been disconnected"
msgstr "Vous n'avez pas été déconnecté"
#: canaille/templates/about.html:12 canaille/themes/default/base.html:104 #: canaille/templates/about.html:12 canaille/themes/default/base.html:104
msgid "About canaille" msgid "About canaille"
msgstr "À propos de canaille" msgstr "À propos de canaille"
@ -1242,6 +1259,33 @@ msgid "You did not authorize applications yet."
msgstr "" msgstr ""
"Vous n'avez pas encore autorisé d'application à accéder à votre profil." "Vous n'avez pas encore autorisé d'application à accéder à votre profil."
#: canaille/templates/oidc/user/logout.html:9
#: canaille/themes/default/base.html:90
msgid "Log out"
msgstr "Déconnexion"
#: canaille/templates/oidc/user/logout.html:10
msgid "Do you want to log out?"
msgstr "Voulez vous vous déconnecter ?"
#: canaille/templates/oidc/user/logout.html:14
#, python-format
msgid "You are currently logged in as %(username)s."
msgstr "Vous êtes identifiés en tant que %(username)s"
#: canaille/templates/oidc/user/logout.html:16
#, python-format
msgid "The application %(client_name)s want to disconnect your account."
msgstr "L'application %(client_name)s demande votre déconnexion."
#: canaille/templates/oidc/user/logout.html:33
msgid "Stay logged"
msgstr "Rester connecté"
#: canaille/templates/oidc/user/logout.html:36
msgid "Logout"
msgstr "Déconnexion"
#: canaille/themes/default/base.html:10 #: canaille/themes/default/base.html:10
msgid "authorization interface" msgid "authorization interface"
msgstr " - Interface de gestion des autorisations" msgstr " - Interface de gestion des autorisations"
@ -1266,10 +1310,6 @@ msgstr "Codes"
msgid "Emails" msgid "Emails"
msgstr "Courriels" msgstr "Courriels"
#: canaille/themes/default/base.html:90
msgid "Log out"
msgstr "Déconnexion"
#~ msgid "Logged in as" #~ msgid "Logged in as"
#~ msgstr "Connecté en tant que" #~ msgstr "Connecté en tant que"

View file

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2022-06-02 15:41+0200\n" "POT-Creation-Date: 2022-06-02 15:50+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -17,7 +17,7 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.10.1\n" "Generated-By: Babel 2.10.1\n"
#: canaille/account.py:93 canaille/account.py:118 canaille/oidc/oauth.py:77 #: canaille/account.py:93 canaille/account.py:118 canaille/oidc/oauth.py:88
msgid "Login failed, please check your information" msgid "Login failed, please check your information"
msgstr "" msgstr ""
@ -342,74 +342,78 @@ msgid "Redirect URIs"
msgstr "" msgstr ""
#: canaille/oidc/clients.py:56 #: canaille/oidc/clients.py:56
msgid "Post logout redirect URIs"
msgstr ""
#: canaille/oidc/clients.py:61
msgid "Grant types" msgid "Grant types"
msgstr "" msgstr ""
#: canaille/oidc/clients.py:68 canaille/templates/oidc/admin/token_view.html:33 #: canaille/oidc/clients.py:73 canaille/templates/oidc/admin/token_view.html:33
msgid "Scope" msgid "Scope"
msgstr "" msgstr ""
#: canaille/oidc/clients.py:74 #: canaille/oidc/clients.py:79
msgid "Response types" msgid "Response types"
msgstr "" msgstr ""
#: canaille/oidc/clients.py:80 #: canaille/oidc/clients.py:85
msgid "Token Endpoint Auth Method" msgid "Token Endpoint Auth Method"
msgstr "" msgstr ""
#: canaille/oidc/clients.py:90 #: canaille/oidc/clients.py:95
msgid "Token audiences" msgid "Token audiences"
msgstr "" msgstr ""
#: canaille/oidc/clients.py:96 #: canaille/oidc/clients.py:101
msgid "Logo URI" msgid "Logo URI"
msgstr "" msgstr ""
#: canaille/oidc/clients.py:101 #: canaille/oidc/clients.py:106
msgid "Terms of service URI" msgid "Terms of service URI"
msgstr "" msgstr ""
#: canaille/oidc/clients.py:106 #: canaille/oidc/clients.py:111
msgid "Policy URI" msgid "Policy URI"
msgstr "" msgstr ""
#: canaille/oidc/clients.py:111 #: canaille/oidc/clients.py:116
msgid "Software ID" msgid "Software ID"
msgstr "" msgstr ""
#: canaille/oidc/clients.py:116 #: canaille/oidc/clients.py:121
msgid "Software Version" msgid "Software Version"
msgstr "" msgstr ""
#: canaille/oidc/clients.py:121 #: canaille/oidc/clients.py:126
msgid "JWK" msgid "JWK"
msgstr "" msgstr ""
#: canaille/oidc/clients.py:126 #: canaille/oidc/clients.py:131
msgid "JKW URI" msgid "JKW URI"
msgstr "" msgstr ""
#: canaille/oidc/clients.py:131 #: canaille/oidc/clients.py:136
msgid "Pre-consent" msgid "Pre-consent"
msgstr "" msgstr ""
#: canaille/oidc/clients.py:149 #: canaille/oidc/clients.py:154
msgid "The client has not been added. Please check your information." msgid "The client has not been added. Please check your information."
msgstr "" msgstr ""
#: canaille/oidc/clients.py:184 #: canaille/oidc/clients.py:190
msgid "The client has been created." msgid "The client has been created."
msgstr "" msgstr ""
#: canaille/oidc/clients.py:222 #: canaille/oidc/clients.py:233
msgid "The client has not been edited. Please check your information." msgid "The client has not been edited. Please check your information."
msgstr "" msgstr ""
#: canaille/oidc/clients.py:248 #: canaille/oidc/clients.py:260
msgid "The client has been edited." msgid "The client has been edited."
msgstr "" msgstr ""
#: canaille/oidc/clients.py:264 #: canaille/oidc/clients.py:276
msgid "The client has been deleted." msgid "The client has been deleted."
msgstr "" msgstr ""
@ -421,30 +425,42 @@ msgstr ""
msgid "The access has been revoked" msgid "The access has been revoked"
msgstr "" msgstr ""
#: canaille/oidc/oauth.py:37 #: canaille/oidc/oauth.py:43
msgid "Personnal information about yourself, such as your name or your gender." msgid "Personnal information about yourself, such as your name or your gender."
msgstr "" msgstr ""
#: canaille/oidc/oauth.py:39 #: canaille/oidc/oauth.py:45
msgid "Your email address." msgid "Your email address."
msgstr "" msgstr ""
#: canaille/oidc/oauth.py:40 #: canaille/oidc/oauth.py:46
msgid "Your postal address." msgid "Your postal address."
msgstr "" msgstr ""
#: canaille/oidc/oauth.py:41 #: canaille/oidc/oauth.py:47
msgid "Your phone number." msgid "Your phone number."
msgstr "" msgstr ""
#: canaille/oidc/oauth.py:42 #: canaille/oidc/oauth.py:48
msgid "Groups you are belonging to" msgid "Groups you are belonging to"
msgstr "" msgstr ""
#: canaille/oidc/oauth.py:125 #: canaille/oidc/oauth.py:136
msgid "You have been successfully logged out." msgid "You have been successfully logged out."
msgstr "" msgstr ""
#: canaille/oidc/oauth.py:317
msgid "You have been disconnected"
msgstr ""
#: canaille/oidc/oauth.py:325
msgid "An error happened during the logout"
msgstr ""
#: canaille/oidc/oauth.py:337
msgid "You have not been disconnected"
msgstr ""
#: canaille/templates/about.html:12 canaille/themes/default/base.html:104 #: canaille/templates/about.html:12 canaille/themes/default/base.html:104
msgid "About canaille" msgid "About canaille"
msgstr "" msgstr ""
@ -1151,6 +1167,33 @@ msgstr ""
msgid "You did not authorize applications yet." msgid "You did not authorize applications yet."
msgstr "" msgstr ""
#: canaille/templates/oidc/user/logout.html:9
#: canaille/themes/default/base.html:90
msgid "Log out"
msgstr ""
#: canaille/templates/oidc/user/logout.html:10
msgid "Do you want to log out?"
msgstr ""
#: canaille/templates/oidc/user/logout.html:14
#, python-format
msgid "You are currently logged in as %(username)s."
msgstr ""
#: canaille/templates/oidc/user/logout.html:16
#, python-format
msgid "The application %(client_name)s want to disconnect your account."
msgstr ""
#: canaille/templates/oidc/user/logout.html:33
msgid "Stay logged"
msgstr ""
#: canaille/templates/oidc/user/logout.html:36
msgid "Logout"
msgstr ""
#: canaille/themes/default/base.html:10 #: canaille/themes/default/base.html:10
msgid "authorization interface" msgid "authorization interface"
msgstr "" msgstr ""
@ -1174,7 +1217,3 @@ msgstr ""
#: canaille/themes/default/base.html:83 #: canaille/themes/default/base.html:83
msgid "Emails" msgid "Emails"
msgstr "" msgstr ""
#: canaille/themes/default/base.html:90
msgid "Log out"
msgstr ""

View file

@ -1,5 +1,9 @@
from urllib.parse import urlsplit
from urllib.parse import urlunsplit
from authlib.integrations.flask_client import OAuth from authlib.integrations.flask_client import OAuth
from authlib.oidc.discovery import get_well_known_url from authlib.oidc.discovery import get_well_known_url
from flask import current_app
from flask import flash from flask import flash
from flask import Flask from flask import Flask
from flask import redirect from flask import redirect
@ -39,6 +43,7 @@ def create_app():
def authorize(): def authorize():
token = oauth.canaille.authorize_access_token() token = oauth.canaille.authorize_access_token()
session["user"] = token.get("userinfo") session["user"] = token.get("userinfo")
session["id_token"] = token["id_token"]
flash("You have been successfully logged in.", "success") flash("You have been successfully logged in.", "success")
return redirect(url_for("index")) return redirect(url_for("index"))
@ -49,7 +54,31 @@ def create_app():
except KeyError: except KeyError:
pass pass
flash("You have been successfully logged out.", "success") flash("You have been successfully logged out", "success")
return redirect(url_for("index"))
oauth.canaille.load_server_metadata()
end_session_endpoint = oauth.canaille.server_metadata.get(
"end_session_endpoint"
)
end_session_url = set_parameter_in_url_query(
end_session_endpoint,
client_id=current_app.config["OAUTH_CLIENT_ID"],
id_token_hint=session["id_token"],
post_logout_redirect_uri=url_for("index", _external=True),
)
return redirect(end_session_url)
return app return app
def set_parameter_in_url_query(url, **kwargs):
split = list(urlsplit(url))
parameters = "&".join(f"{key}={value}" for key, value in kwargs.items())
if split[3]:
split[3] = f"{split[3]}&{parameters}"
else:
split[3] = parameters
return urlunsplit(split)

View file

@ -79,6 +79,7 @@ oauthClientName: Client1
oauthClientContact: admin@mydomain.tld oauthClientContact: admin@mydomain.tld
oauthClientURI: http://localhost:5001 oauthClientURI: http://localhost:5001
oauthRedirectURIs: http://localhost:5001/authorize oauthRedirectURIs: http://localhost:5001/authorize
oauthPostLogoutRedirectURI: http://localhost:5001/
oauthGrantType: authorization_code oauthGrantType: authorization_code
oauthGrantType: refresh_token oauthGrantType: refresh_token
oauthScope: openid oauthScope: openid
@ -98,6 +99,7 @@ oauthClientName: Client2
oauthClientContact: admin@mydomain.tld oauthClientContact: admin@mydomain.tld
oauthClientURI: http://localhost:5002 oauthClientURI: http://localhost:5002
oauthRedirectURIs: http://localhost:5002/authorize oauthRedirectURIs: http://localhost:5002/authorize
oauthPostLogoutRedirectURI: http://localhost:5002/
oauthGrantType: authorization_code oauthGrantType: authorization_code
oauthGrantType: refresh_token oauthGrantType: refresh_token
oauthScope: openid oauthScope: openid

View file

@ -1,10 +1,13 @@
import datetime import datetime
import pytest import pytest
from authlib.oidc.core.grants.util import generate_id_token
from canaille.oidc.models import AuthorizationCode from canaille.oidc.models import AuthorizationCode
from canaille.oidc.models import Client from canaille.oidc.models import Client
from canaille.oidc.models import Consent from canaille.oidc.models import Consent
from canaille.oidc.models import Token from canaille.oidc.models import Token
from canaille.oidc.oauth2utils import generate_user_info
from canaille.oidc.oauth2utils import get_jwt_config
from werkzeug.security import gen_salt from werkzeug.security import gen_salt
@ -35,6 +38,7 @@ def client(testclient, other_client, slapd_connection):
policy_uri="https://mydomain.tld/policy", policy_uri="https://mydomain.tld/policy",
jwk_uri="https://mydomain.tld/jwk", jwk_uri="https://mydomain.tld/jwk",
token_endpoint_auth_method="client_secret_basic", token_endpoint_auth_method="client_secret_basic",
post_logout_redirect_uris=["https://mydomain.tld/disconnected"],
) )
c.audience = [c.dn, other_client.dn] c.audience = [c.dn, other_client.dn]
c.save() c.save()
@ -73,6 +77,7 @@ def other_client(testclient, slapd_connection):
policy_uri="https://myotherdomain.tld/policy", policy_uri="https://myotherdomain.tld/policy",
jwk_uri="https://myotherdomain.tld/jwk", jwk_uri="https://myotherdomain.tld/jwk",
token_endpoint_auth_method="client_secret_basic", token_endpoint_auth_method="client_secret_basic",
post_logout_redirect_uris=["https://myotherdomain.tld/disconnected"],
) )
c.audience = [c.dn] c.audience = [c.dn]
c.save() c.save()
@ -131,6 +136,16 @@ def token(testclient, client, user, slapd_connection):
pass pass
@pytest.fixture
def id_token(testclient, client, user, slapd_connection):
return generate_id_token(
{},
generate_user_info(user.dn, client.scope),
aud=client.client_id,
**get_jwt_config(None)
)
@pytest.fixture @pytest.fixture
def consent(testclient, client, user, slapd_connection): def consent(testclient, client, user, slapd_connection):
t = Consent( t = Consent(

View file

@ -38,6 +38,7 @@ def test_client_add(testclient, logged_admin):
"jwk_uri": "https://foo.bar/jwks.json", "jwk_uri": "https://foo.bar/jwks.json",
"audience": [], "audience": [],
"preconsent": False, "preconsent": False,
"post_logout_redirect_uris": ["https://foo.bar/disconnected"],
} }
for k, v in data.items(): for k, v in data.items():
res.form[k].force_value(v) res.form[k].force_value(v)
@ -78,6 +79,7 @@ def test_client_edit(testclient, client, logged_admin, other_client):
"jwk_uri": "https://foo.bar/jwks.json", "jwk_uri": "https://foo.bar/jwks.json",
"audience": [client.dn, other_client.dn], "audience": [client.dn, other_client.dn],
"preconsent": True, "preconsent": True,
"post_logout_redirect_uris": ["https://foo.bar/disconnected"],
} }
for k, v in data.items(): for k, v in data.items():
res.forms["clientadd"][k].force_value(v) res.forms["clientadd"][k].force_value(v)

View file

@ -0,0 +1,331 @@
from authlib.oidc.core.grants.util import generate_id_token
from canaille.oidc.oauth2utils import generate_user_info
from canaille.oidc.oauth2utils import get_jwt_config
def test_end_session(testclient, slapd_connection, logged_user, client, id_token):
testclient.get(f"/profile/{logged_user.uid[0]}", status=200)
post_logout_redirect_url = "https://mydomain.tld/disconnected"
res = testclient.get(
"/oauth/end_session",
params={
"id_token_hint": id_token,
"logout_hint": logged_user.uid[0],
"client_id": client.client_id,
"post_logout_redirect_uri": post_logout_redirect_url,
"state": "foobar",
},
status=302,
)
assert res.location.startswith(post_logout_redirect_url)
with testclient.session_transaction() as sess:
assert not sess.get("user_dn")
testclient.get(f"/profile/{logged_user.uid[0]}", status=403)
def test_end_session_no_client_id(
testclient, slapd_connection, logged_user, client, id_token
):
testclient.get(f"/profile/{logged_user.uid[0]}", status=200)
post_logout_redirect_url = "https://mydomain.tld/disconnected"
res = testclient.get(
"/oauth/end_session",
params={
"id_token_hint": id_token,
"logout_hint": logged_user.uid[0],
"post_logout_redirect_uri": post_logout_redirect_url,
"state": "foobar",
},
status=302,
)
assert res.location.startswith(post_logout_redirect_url)
with testclient.session_transaction() as sess:
assert not sess.get("user_dn")
testclient.get(f"/profile/{logged_user.uid[0]}", status=403)
def test_no_redirect_uri_no_redirect(
testclient, slapd_connection, logged_user, client, id_token
):
testclient.get(f"/profile/{logged_user.uid[0]}", status=200)
res = testclient.get(
"/oauth/end_session",
params={
"id_token_hint": id_token,
"logout_hint": logged_user.uid[0],
"client_id": client.client_id,
"state": "foobar",
},
status=302,
)
assert res.location == "/"
with testclient.session_transaction() as sess:
assert not sess.get("user_dn")
testclient.get(f"/profile/{logged_user.uid[0]}", status=403)
def test_bad_redirect_uri_no_redirect(
testclient, slapd_connection, logged_user, client, id_token
):
testclient.get(f"/profile/{logged_user.uid[0]}", status=200)
post_logout_redirect_url = "https://mydomain.tld/invalid-uri"
res = testclient.get(
"/oauth/end_session",
params={
"id_token_hint": id_token,
"logout_hint": logged_user.uid[0],
"client_id": client.client_id,
"post_logout_redirect_uri": post_logout_redirect_url,
"state": "foobar",
},
status=302,
)
assert res.location == "/"
with testclient.session_transaction() as sess:
assert not sess.get("user_dn")
testclient.get(f"/profile/{logged_user.uid[0]}", status=403)
def test_no_client_hint_no_redirect(
testclient, slapd_connection, logged_user, client, id_token
):
testclient.get(f"/profile/{logged_user.uid[0]}", status=200)
post_logout_redirect_url = "https://mydomain.tld/disconnected"
res = testclient.get(
"/oauth/end_session",
params={
"logout_hint": logged_user.uid[0],
"post_logout_redirect_uri": post_logout_redirect_url,
"state": "foobar",
},
status=200,
)
res = res.form.submit(name="answer", value="logout", status=302)
res = res.follow(status=302)
assert res.location == "/"
with testclient.session_transaction() as sess:
assert not sess.get("user_dn")
testclient.get(f"/profile/{logged_user.uid[0]}", status=403)
def test_no_jwt_logout(testclient, slapd_connection, logged_user, client):
testclient.get(f"/profile/{logged_user.uid[0]}", status=200)
post_logout_redirect_url = "https://mydomain.tld/disconnected"
res = testclient.get(
"/oauth/end_session",
params={
"logout_hint": logged_user.uid[0],
"client_id": client.client_id,
"post_logout_redirect_uri": post_logout_redirect_url,
"state": "foobar",
},
status=200,
)
res = res.form.submit(name="answer", value="logout", status=302)
res = res.follow(status=302)
with testclient.session_transaction() as sess:
assert not sess.get("user_dn")
assert res.location.startswith(post_logout_redirect_url)
testclient.get(f"/profile/{logged_user.uid[0]}", status=403)
def test_no_jwt_no_logout(testclient, slapd_connection, logged_user, client):
testclient.get(f"/profile/{logged_user.uid[0]}", status=200)
post_logout_redirect_url = "https://mydomain.tld/disconnected"
res = testclient.get(
"/oauth/end_session",
params={
"logout_hint": logged_user.uid[0],
"client_id": client.client_id,
"post_logout_redirect_uri": post_logout_redirect_url,
"state": "foobar",
},
status=200,
)
res = res.form.submit(name="answer", value="stay", status=302)
with testclient.session_transaction() as sess:
assert sess.get("user_dn")
assert res.location == "/"
testclient.get(f"/profile/{logged_user.uid[0]}", status=200)
def test_jwt_not_issued_here(
testclient, slapd_connection, logged_user, client, id_token
):
testclient.app.config["JWT"]["ISS"] = "https://foo.bar"
testclient.get(f"/profile/{logged_user.uid[0]}", status=200)
post_logout_redirect_url = "https://mydomain.tld/disconnected"
res = testclient.get(
"/oauth/end_session",
params={
"id_token_hint": id_token,
"logout_hint": logged_user.uid[0],
"client_id": client.client_id,
"post_logout_redirect_uri": post_logout_redirect_url,
"state": "foobar",
},
status=200,
)
assert res.json == {
"message": "id_token_hint has not been issued here",
"status": "error",
}
def test_bad_client_hint(testclient, slapd_connection, logged_user, client):
id_token = generate_id_token(
{},
generate_user_info(logged_user.dn, client.scope),
aud="another_client_id",
**get_jwt_config(None),
)
testclient.get(f"/profile/{logged_user.uid[0]}", status=200)
post_logout_redirect_url = "https://mydomain.tld/disconnected"
res = testclient.get(
"/oauth/end_session",
params={
"id_token_hint": id_token,
"logout_hint": logged_user.uid[0],
"client_id": client.client_id,
"post_logout_redirect_uri": post_logout_redirect_url,
"state": "foobar",
},
status=200,
)
assert res.json == {
"message": "id_token_hint and client_id don't match",
"status": "error",
}
def test_bad_user_id_token_mismatch(
testclient, slapd_connection, logged_user, client, admin
):
testclient.get(f"/profile/{logged_user.uid[0]}", status=200)
id_token = generate_id_token(
{},
generate_user_info(admin.dn, client.scope),
aud=client.client_id,
**get_jwt_config(None),
)
post_logout_redirect_url = "https://mydomain.tld/disconnected"
res = testclient.get(
"/oauth/end_session",
params={
"id_token_hint": id_token,
"logout_hint": logged_user.uid[0],
"client_id": client.client_id,
"post_logout_redirect_uri": post_logout_redirect_url,
"state": "foobar",
},
status=200,
)
res = res.form.submit(name="answer", value="logout", status=302)
res = res.follow(status=302)
with testclient.session_transaction() as sess:
assert not sess.get("user_dn")
assert res.location.startswith(post_logout_redirect_url)
testclient.get(f"/profile/{logged_user.uid[0]}", status=403)
def test_bad_user_hint(
testclient, slapd_connection, logged_user, client, id_token, admin
):
testclient.get(f"/profile/{logged_user.uid[0]}", status=200)
post_logout_redirect_url = "https://mydomain.tld/disconnected"
res = testclient.get(
"/oauth/end_session",
params={
"id_token_hint": id_token,
"logout_hint": admin.uid[0],
"client_id": client.client_id,
"post_logout_redirect_uri": post_logout_redirect_url,
"state": "foobar",
},
status=200,
)
res = res.form.submit(name="answer", value="logout", status=302)
res = res.follow(status=302)
with testclient.session_transaction() as sess:
assert not sess.get("user_dn")
assert res.location.startswith(post_logout_redirect_url)
testclient.get(f"/profile/{logged_user.uid[0]}", status=403)
def test_no_jwt_bad_csrf(testclient, slapd_connection, logged_user, client):
testclient.get(f"/profile/{logged_user.uid[0]}", status=200)
post_logout_redirect_url = "https://mydomain.tld/disconnected"
res = testclient.get(
"/oauth/end_session",
params={
"logout_hint": logged_user.uid[0],
"client_id": client.client_id,
"post_logout_redirect_uri": post_logout_redirect_url,
"state": "foobar",
},
status=200,
)
form = res.form
form["csrf_token"] = "foobar"
res = form.submit(name="answer", value="logout", status=200)
assert "An error happened during the logout" in res
res = res.form.submit(name="answer", value="logout", status=302)
res = res.follow(status=302)
with testclient.session_transaction() as sess:
assert not sess.get("user_dn")
assert res.location.startswith(post_logout_redirect_url)
testclient.get(f"/profile/{logged_user.uid[0]}", status=403)