diff --git a/modules/core/templates/logout-iframe-wrapper.twig b/modules/core/templates/logout-iframe-wrapper.twig index 7c9e415d072d9d25553bdb7e8eee4b05c76a4810..a1baa200c0560f829872d64d6492d0cd951f701b 100644 --- a/modules/core/templates/logout-iframe-wrapper.twig +++ b/modules/core/templates/logout-iframe-wrapper.twig @@ -1,16 +1,2 @@ - {% set pagetitle = '{logout:progress}'|trans %} -{% extends "base.twig" %} - -{% block content %} - {# pretty arbitrary height, but should have enough safety margins for most cases #} - {% set iframeHeight = (25 + (SPs|length * 4)) %} - - <iframe style="width:100%; height: {{ iframeHeight }}em; border:0;" src="logout-iframe.php?type=embed&id={{ auth_state|escape('url') }}"></iframe> - - {% for assocId, sp in SPs %} - {% if attribute(sp, 'core:Logout-IFrame:State') == 'inprogress' %} - <iframe style="width:0; height:0; border:0;" src="{{ attribute(sp, 'core:Logout-IFrame:URL')|escape('html') }}</iframe> - {% endif %} - {% endfor %} -{% endblock %} +{% extends "@core/logout-iframe.twig" %} diff --git a/modules/core/templates/logout-iframe.twig b/modules/core/templates/logout-iframe.twig new file mode 100644 index 0000000000000000000000000000000000000000..e3959ba6282ceb0891b5dea7bb20067921ad25bb --- /dev/null +++ b/modules/core/templates/logout-iframe.twig @@ -0,0 +1,123 @@ +{% set pagetitle = 'Logging out...'|trans %} +{% extends "base.twig" %} + +{% block preload %} + + <link rel="preload" href="{{ asset('js/logout.js') }}" as="script"> + {%- if type != "init" %} + {%- set content = '2' %} + {%- if remaining_services|length == 0 %} + {%- set content = '0; url=logout-iframe-done.php?id=' ~ auth_state %} + {%- endif %} + + <meta http-equiv="refresh" content="{{ content }}"> + {% endif %} +{% endblock preload %} + +{% block content %} + + <h1>{{ pagetitle }}</h1> + {%- if terminated_service %} + {%- set SP = terminated_service['name']|translateFromArray|default('the service'|trans)|e %} + + <p>{% trans %}You are now successfully logged out from {{ SP }}.{% endtrans %}</p> + {%- endif %} + {%- if remaining_services %} + {%- set failed = false %} + {%- set remaining = 0 %} + {%- if remaining_services|length > 0 %} + + <p>{% trans %}You are also logged in on these services:{% endtrans %}</p> + {%- endif %} + + <div class="custom-restricted-width"> + <ul class="fa-ul"> + {%- for key, sp in remaining_services %} + {%- set timeout = 5 %} + {%- set name = sp['metadata']['name']|translateFromArray|default(sp['entityID']) %} + {%- set icon = 'circle-o-notch' %} + {%- if sp['status'] == 'completed' %} + {%- set icon = 'check-circle' %} + {%- elseif sp['status'] == 'failed' %} + {%- set icon = 'exclamation-circle' %} + {%- set failed = true %} + {%- elseif (sp['status'] == 'onhold' or sp['status'] == 'inprogress') %} + {%- set remaining = remaining + 1 %} + {%- endif %} + {%- if type == 'nojs' and sp['status'] == 'inprogress' %} + {%- set icon = icon ~ ' fa-spin' %} + {%- endif %} + + <li id="sp-{{ key }}" data-id="{{ key }}" data-status="{{ sp['status'] }}" + {#- #} data-timeout="{{ timeout }}"> + <span class="fa-li"><i id="icon-{{ key }}" class="fa fa-{{ icon }}"></i></span> + {{ name }} + {%- if sp['status'] != 'completed' and sp['status'] != 'failed' %} + {%- if type == 'nojs' %} + + <iframe id="iframe-{{ key }}" class="hidden" src="{{ sp['logoutURL'] }}"></iframe> + {%- else %} + + <iframe id="iframe-{{ key }}" class="hidden" data-url="{{ sp['logoutURL'] }}"></iframe> + {%- endif %} + {%- else %} + {%- if sp['status'] == 'failed' %} + ({% trans %}logout is not supported{% endtrans %}) + {%- endif %} + {%- endif %} + + </li> + {%- endfor %} + + </ul> + </div> + <br> + <div id="error-message"{% if not failed or type == 'init' %} class="hidden"{% endif %}> + <div class="message-box error"> + {% trans %}Unable to log out of one or more services. To ensure that all your + {#- #} sessions are closed, you are encouraged to <i>close your webbrowser</i>.{% endtrans %} + </div> + </div> + <form id="error-form" action="logout-iframe-done.php" + {%- if (not failed or type == 'init') and remaining %} class="hidden"{% endif %}> + <input type="hidden" name="id" value="{{ auth_state }}"> + <button type="submit" id="btn-continue" name="ok" class="pure-button pure-button-red"> + {%- trans %}Continue{% endtrans -%} + </button> + </form> + <div id="original-actions"{% if type != 'init' %} class="hidden"{% endif %}> + <p>{% trans %}Do you want to logout from all the services above?{% endtrans %}</p> + <div class="pure-button-group two-elements"> + <form id="startform" action="logout-iframe.php"> + <input type="hidden" name="id" value="{{ auth_state }}"> + <noscript> + <input type="hidden" name="type" value="nojs" id="logout-type-selector"> + </noscript> + <button type="submit" id="btn-all" name="ok" class="pure-button pure-button-red"> + {%- trans %}Yes, all services{% endtrans -%} + </button> + </form> + <form action="logout-iframe-done.php"> + <input type="hidden" name="id" value="{{ auth_state }}"> + <input type="hidden" name="cancel" value=""> + <button id="btn-cancel" class="pure-button" type="submit"> + {%- if terminated_service %}{% trans %}No, only {{ SP }}{% endtrans %} + {%- else %}{% trans %}No{% endtrans %}{% endif -%} + </button> + </form> + </div> + </div> + {%- else %} + <form id="error-form" action="logout-iframe-done.php"> + <input type="hidden" name="id" value="{{ auth_state }}"> + <button type="submit" id="btn-continue" name="ok" class="pure-button pure-button-red"> + {%- trans %}Continue{% endtrans -%} + </button> + </form> + {% endif %} +{% endblock %} + +{% block postload %} + + <script src="{{ asset('js/logout.js') }}"></script> +{% endblock postload %} diff --git a/modules/core/www/idp/logout-iframe.php b/modules/core/www/idp/logout-iframe.php index 5e58666aedfdd2c12a3b5bc1011c89fe7656c582..ea0024942a2158205a7c12d60d46d25c7a5e813f 100644 --- a/modules/core/www/idp/logout-iframe.php +++ b/modules/core/www/idp/logout-iframe.php @@ -99,15 +99,21 @@ foreach ($state['core:Logout-IFrame:Associations'] as $association) { $mdset = 'adfs-sp-remote'; } + if ($association['core:Logout-IFrame:State'] === 'completed') { + continue; + } + $remaining[$key] = [ 'id' => $association['id'], 'expires_on' => $association['Expires'], 'entityID' => $association['saml:entityID'], 'subject' => $association['saml:NameID'], 'status' => $association['core:Logout-IFrame:State'], - 'logoutURL' => $association['core:Logout-IFrame:URL'], 'metadata' => $mdh->getMetaDataConfig($association['saml:entityID'], $mdset)->toArray(), ]; + if (isset($association['core:Logout-IFrame:URL'])) { + $remaining[$key]['logoutURL'] = $association['core:Logout-IFrame:URL']; + } if (isset($association['core:Logout-IFrame:Timeout'])) { $remaining[$key]['timeout'] = $association['core:Logout-IFrame:Timeout']; } diff --git a/src/js/logout/logout.js b/src/js/logout/logout.js new file mode 100644 index 0000000000000000000000000000000000000000..aec766b8ffd7a8f07f91f4e3b311938d76ba9f7c --- /dev/null +++ b/src/js/logout/logout.js @@ -0,0 +1,255 @@ +/** + * This class is used for the logout page. + * + * It allows the user to start logout from all the services where a session exists (if any). Logout will be + * triggered by loading an iframe where we send a SAML logout request to the SingleLogoutService endpoint of the + * given SP. After successful response back from the SP, we will load a small template in the iframe that loads + * this class again (IFrameLogoutHandler branch of the constructor), and sends a message to the main page + * (core:logout-iframe branch). + * + * The iframes communicate the logout status for their corresponding association via an event message, for which the + * main page is listening (the clearAssociation() method). Upon reception of a message, we'll check if there was an + * error or not, and call the appropriate method (either completed() or failed()). + */ +class SimpleSAMLLogout { + constructor(page) { + if (page === 'core:logout-iframe') { // main page + this.populateData(); + if (Object.keys(this.sps).length === 0) { + // all SPs completed logout, this was a reload + this.btncontinue.click(); + } + this.btnall.on('click', this.initLogout.bind(this)); + window.addEventListener('message', this.clearAssociation.bind(this), false); + + } else if (page === 'IFrameLogoutHandler') { // iframe + let data = $('i[id="data"]'); + let message = { + spId: $(data).data('spid') + }; + if ($(data).data('error')) { + message.error = $(data).data('error'); + } + + window.parent.postMessage(JSON.stringify(message), SimpleSAMLLogout.getOrigin()); + } + } + + + /** + * Clear an association when it is signaled from an iframe (either failed or completed). + * + * @param event The event containing the message from the iframe. + */ + clearAssociation(event) { + if (event.origin !== SimpleSAMLLogout.getOrigin()) { + // we don't accept events from other origins + return; + } + let data = JSON.parse(event.data); + if (typeof data.error === 'undefined') { + this.completed(data.spId); + } else { + this.failed(data.spId, data.error); + } + + if (Object.keys(this.sps).length === 0) { + if (this.nfailed === 0) { + // all SPs successfully logged out, continue w/o user interaction + this.btncontinue.click(); + } + } + } + + + /** + * Mark logout as completed for a given SP. + * + * This method will be called by the SimpleSAML\IdP\IFrameLogoutHandler class upon successful logout from the SP. + * + * @param id The ID of the SP that completed logout successfully. + */ + completed(id) { + if (typeof this.sps[id] === 'undefined') { + return; + } + + this.sps[id].icon.removeClass('fa-spin'); + this.sps[id].icon.removeClass('fa-circle-o-notch'); + this.sps[id].icon.addClass('fa-check-circle'); + this.sps[id].element.toggle(); + delete this.sps[id]; + this.finish(); + } + + + /** + * Mark logout as failed for a given SP. + * + * This method will be called by the SimpleSAML\IdP\IFrameLogoutHandler class upon logout failure from the SP. + * + * @param id The ID of the SP that failed to complete logout. + * @param reason The reason why logout failed. + */ + failed(id, reason) { + if (typeof this.sps[id] === 'undefined') { + return; + } + + this.sps[id].element.addClass('error'); + $(this.sps[id].icon).removeClass('fa-spin fa-circle-o-notch'); + $(this.sps[id].icon).addClass('fa-exclamation-circle'); + + if (this.errmsg.hasClass('hidden')) { + this.errmsg.removeClass('hidden'); + } + if (this.errfrm.hasClass('hidden')) { + this.errfrm.removeClass('hidden'); + } + + delete this.sps[id]; + this.nfailed++; + this.finish(); + } + + + /** + * Finish the logout process, acting according to the current situation: + * + * - If there were failures, an error message is shown telling the user to close the browser. + * - If everything went ok, then we just continue back to the service that started logout. + * + * Note: this method won't do anything if there are SPs pending logout (e.g. waiting for the timeout). + */ + finish() { + if (Object.keys(this.sps).length > 0) { // pending services + return; + } + + if (typeof this.timeout !== 'undefined') { + clearTimeout(this.timeout); + } + + if (this.nfailed > 0) { // some services failed to log out + this.errmsg.removeClass('hidden'); + this.errfrm.removeClass('hidden'); + this.actions.addClass('hidden'); + + } else { // all services done + this.btncontinue.click(); + } + } + + + /** + * Get the origin of the current page. + */ + static getOrigin() { + let origin = window.location.origin; + if (!origin) { + // IE < 11 does not support window.location.origin + origin = window.location.protocol + "//" + window.location.hostname + + (window.location.port ? ':' + window.location.port: ''); + } + return origin; + } + + + /** + * This method starts logout on all SPs where we are currently logged in. + * + * @param event The click event on the "Yes, all services" button. + */ + initLogout(event) { + event.preventDefault(); + + this.btnall.prop('disabled', true); + this.btncancel.prop('disabled', true); + Object.keys(this.sps).forEach((function (id) { + this.sps[id].status = 'inprogress'; + this.sps[id].startTime = (new Date()).getTime(); + this.sps[id].iframe.attr('src', this.sps[id].iframe.data('url')); + this.sps[id].icon.addClass('fa-spin'); + }).bind(this)); + this.initTimeout(); + } + + + /** + * Set timeouts for all logout operations. + * + * If an SP didn't reply by the timeout, we'll mark it as failed. + */ + initTimeout() { + let timeout = 10; + + for (const id in this.sps) { + if (typeof id === 'undefined') { + continue; + } + if (!this.sps.hasOwnProperty(id)) { + continue; + } + if (this.sps[id].status !== 'inprogress') { + continue; + } + let now = ((new Date()).getTime() - this.sps[id].startTime) / 1000; + + if (this.sps[id].timeout <= now) { + this.failed(id, 'Timed out', window.document); + } else { + // get the lowest timeout we have + if ((this.sps[id].timeout - now) < timeout) { + timeout = this.sps[id].timeout - now; + } + } + } + + if (Object.keys(this.sps).length > 0) { + // we have associations left, check them again as soon as one expires + this.timeout = setTimeout(this.initTimeout.bind(this), timeout * 1000); + } else { + this.finish(); + } + } + + + /** + * This method populates the data we need from data-* properties in the page. + */ + populateData() { + this.sps = {}; + this.btnall = $('button[id="btn-all"]'); + this.btncancel = $('button[id="btn-cancel"]'); + this.btncontinue = $('button[id="btn-continue"]'); + this.actions = $('div[id="original-actions"]'); + this.errmsg = $('div[id="error-message"]'); + this.errfrm = $('form[id="error-form"]'); + this.nfailed = 0; + let that = this; + + // initialise SP status and timeout arrays + $('li[id^="sp-"]').each(function () { + let id = $(this).data('id'); + let iframe = $('iframe[id="iframe-'+id+'"]'); + let status = $(this).data('status'); + + switch (status) { + case 'failed': + that.nfailed++; + case 'completed': + return; + } + + that.sps[id] = { + status: status, + timeout: $(this).data('timeout'), + element: $(this), + iframe: iframe, + icon: $('i[id="icon-'+id+'"]'), + }; + }); + } +} + +export default SimpleSAMLLogout; \ No newline at end of file diff --git a/src/js/logout/main.js b/src/js/logout/main.js new file mode 100644 index 0000000000000000000000000000000000000000..16c508f9ce3506785070cfada90d71c12f9a7f73 --- /dev/null +++ b/src/js/logout/main.js @@ -0,0 +1,5 @@ +import SimpleSAMLLogout from './logout.js'; + +$(document).ready(function () { + new SimpleSAMLLogout($('body').attr('id')); +}); \ No newline at end of file diff --git a/templates/IFrameLogoutHandler.twig b/templates/IFrameLogoutHandler.twig index ad4d4a69856df57610541a23844c1d6f8046f667..946dd71a2c934cd2be838e810dc30977877841c1 100644 --- a/templates/IFrameLogoutHandler.twig +++ b/templates/IFrameLogoutHandler.twig @@ -1,16 +1,16 @@ -<!DOCTYPE html> -<html> - <head> - <title>Logout response from {{ assocId|escape('html') }}</title> - <script> +{% set data = {}|merge({ "spid": spId }) %} {% if errorMsg is defined %} - window.parent.logoutFailed("{{ spId }}", "{{ errorMsg|escape }}"); -{% else %} - window.parent.logoutCompleted("{{ spId }}"); + {% set data = data|merge({ "error": errorMsg }) %} {% endif %} - </script> - </head> - <body> - </body> -</html> +{% extends "base.twig" %} +{% block header %}{% endblock header %} +{% block footer %}{% endblock footer %} +{% block content %} + + <i id="data"{% for k,v in data %} data-{{ k }}="{{ v }}"{% endfor %}></i> +{% endblock content %} +{% block postload %} + + <script src="{{ asset('js/logout.js') }}"></script> +{% endblock postload %} diff --git a/webpack.config.js b/webpack.config.js index 2dff590d58a232a22edb9060b4ff94c9d3758f57..1237e29c1b8d9a13d360fe594e2c3c934856f57c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -18,6 +18,7 @@ module.exports = environment => { return { entry: { bundle: './src/js/bundle', + logout: './src/js/logout/main', stylesheet: './src/js/style' }, output: {