diff --git a/uffd/oauth2/models.py b/uffd/oauth2/models.py index dea62cd9c1aabd42390367d0c926238c3557cb46..cdc3b9f40e318b2da1030ae7ceb327dc28fd9d88 100644 --- a/uffd/oauth2/models.py +++ b/uffd/oauth2/models.py @@ -5,7 +5,7 @@ from uffd.database import db from uffd.user.models import User class OAuth2Client: - def __init__(self, client_id, client_secret, redirect_uris, required_group=None): + def __init__(self, client_id, client_secret, redirect_uris, required_group=None, logout_urls=None): self.client_id = client_id self.client_secret = client_secret # We only support the Authorization Code Flow for confidential (server-side) clients @@ -13,6 +13,12 @@ class OAuth2Client: self.redirect_uris = redirect_uris self.default_scopes = ['profile'] self.required_group = required_group + self.logout_urls = [] + for url in (logout_urls or []): + if isinstance(url, str): + self.logout_urls.append(['GET', url]) + else: + self.logout_urls.append(url) @classmethod def from_id(cls, client_id): diff --git a/uffd/oauth2/templates/logout.html b/uffd/oauth2/templates/logout.html new file mode 100644 index 0000000000000000000000000000000000000000..c414e6a7838289d82d66df673978f209a3b3a803 --- /dev/null +++ b/uffd/oauth2/templates/logout.html @@ -0,0 +1,98 @@ +{% extends 'base.html' %} + +{% block body %} +<div class="row mt-2 justify-content-center"> + <div class="col-lg-6 col-md-10" style="background: #f7f7f7; box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3); padding: 30px;"> + <div class="text-center"> + <img alt="CCC logo" src="{{ url_for("static", filename="chaosknoten.png") }}" class="col-lg-8 col-md-12" > + </div> + <div class="col-12"> + <h2 class="text-center">Logout</h2> + </div> + + <div class="col-12"> + <noscript> + <div class="alert alert-warning" role="alert">Javascript is required for automatic logout</div> + </noscript> + <p>While you successfully logged out of the Single-Sign-On service, you may still be logged in on these services:</p> + <ul> + {% for client in clients if client.logout_urls %} + <li class="client" data-urls='{{ client.logout_urls|tojson }}'> + {{ client.client_id }} + <span class="status-active spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span> + <i class="status-success fas fa-check d-none"></i> + <i class="status-failed fas fa-exclamation d-none"></i> + </li> + {% endfor %} + </ul> + + <p> + Please wait until you have been automatically logged out of all services or make sure of this yourself. + </p> + + <button id="retry-button" class="btn btn-block btn-primary d-none" disabled> + <span id="cont-text">Logging you out on all services ...</span> + </button> + + <a href="{{ request.values.get('ref') or '/' }}" class="btn btn-block btn-secondary"> + <span>Skip this and continue</span> + </a> + + </div> + </div> +</div> + +<script> +function logout_services() { + $("#retry-button").prop('disabled', true); + let all_promises = []; + $("li.client").each(function () { + let elem = $(this); + let p = new Promise(function (resolve, reject) { + elem.find('.status-active').removeClass('d-none'); + elem.find('.status-success').addClass('d-none'); + elem.find('.status-failed').addClass('d-none'); + resolve(); + }); + elem.data('urls').forEach(function (url) { + p = p.then(function () { + return fetch(url[1], {method: url[0], credentials: 'include'}) + .then(function (resp) { + if (!resp.ok) + throw new Error('Server error'); + }); + }); + }); + p = p.then(function () { + console.log('done', elem); + elem.find('.status-active').addClass('d-none'); + elem.find('.status-success').removeClass('d-none'); + elem.removeClass('client'); + }) + .catch(function (err) { + elem.find('.status-active').addClass('d-none'); + elem.find('.status-failed').removeClass('d-none'); + console.log(err); + throw err; + }); + all_promises.push(p); + }); + Promise.allSettled(all_promises).then(function (results) { + console.log(results); + for (result of results) { + if (result.status == 'rejected') + throw result.reason; + } + $('#cont-text').text('Done, redirecting ...'); + window.location = {{ (request.values.get('ref') or '/')|tojson }}; + }).catch(function (err) { + $("#retry-button").prop('disabled', false); + $('#cont-text').text('Log out failed on some services. Retry?'); + }); +} + +$("#retry-button").removeClass('d-none'); +$("#retry-button").on('click', logout_services); +logout_services(); +</script> +{% endblock %} diff --git a/uffd/oauth2/views.py b/uffd/oauth2/views.py index 586b11d9f87f3b18b54f713a3070cc279dd07df9..50b808fc3b87d6efda4d817a867915753bd08f52 100644 --- a/uffd/oauth2/views.py +++ b/uffd/oauth2/views.py @@ -1,7 +1,7 @@ import datetime import functools -from flask import Blueprint, request, jsonify, render_template +from flask import Blueprint, request, jsonify, render_template, session, redirect from werkzeug.datastructures import ImmutableMultiDict from flask_oauthlib.provider import OAuth2Provider @@ -86,6 +86,9 @@ def authorize(*args, **kwargs): # pylint: disable=unused-argument # service access to his data. Since we only have trusted services (the # clients defined in the server config), we don't ask for consent. client = kwargs['request'].client + session['oauth2-clients'] = session.get('oauth2-clients', []) + if client.client_id not in session['oauth2-clients']: + session['oauth2-clients'].append(client.client_id) return client.access_allowed(get_current_user()) @bp.route('/token', methods=['GET', 'POST']) @@ -113,3 +116,17 @@ def error(): err = args.pop('error', 'unknown') error_description = args.pop('error_description', '') return render_template('error.html', error=err, error_description=error_description, args=args) + +@bp.app_url_defaults +def inject_logout_params(endpoint, values): + if endpoint != 'oauth2.logout' or not session.get('oauth2-clients'): + return + values['client_ids'] = ','.join(session['oauth2-clients']) + +@bp.route('/logout') +def logout(): + if not request.values.get('client_ids'): + return redirect(request.values.get('ref', '/')) + client_ids = request.values['client_ids'].split(',') + clients = [OAuth2Client.from_id(client_id) for client_id in client_ids] + return render_template('logout.html', clients=clients) diff --git a/uffd/session/views.py b/uffd/session/views.py index 9badeb3ca1d06b0b5060c79a19e32f8fd2550b16..2178055cac75294855382d04c0ced951cd1f8aa9 100644 --- a/uffd/session/views.py +++ b/uffd/session/views.py @@ -11,8 +11,11 @@ bp = Blueprint("session", __name__, template_folder='templates', url_prefix='/') @bp.route("/logout") def logout(): + # The oauth2 module takes data from `session` and injects it into the url, + # so we need to build the url BEFORE we clear the session! + resp = redirect(url_for('oauth2.logout', ref=url_for('.login'))) session.clear() - return redirect(url_for('.login')) + return resp @bp.route("/login", methods=('GET', 'POST')) def login():