From 77be0a6d3769dc14c204c0a647f84b42ba48cf5c Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Thu, 5 Dec 2024 16:04:34 +1000 Subject: [PATCH] Add a revalidate modal that can be used outside of the Settings flow. (#283) * Add a revalidate modal that can be used outside of the Settings flow. This is for use on the Plugin Directory. * Ensure the event bubbles. * Add additional information about the revalidation state. * Set a cookie when a session is validated. * Add an implementation of 'prompt on click' of 2fa for required actions. * Have the cookie value state the expiration as well, since JS can't access the expiry value of a cookie. * Document that the cookie is not an auth cookie, just a helper for JS. * Namespace the revalidation methods. * Add a message option, to allow presenting a custom reason to do 2FA. * Add auth_redirect(). * Add documentation of how to use this. --- revalidation/README.md | 61 +++++++++++++++ revalidation/index.php | 150 ++++++++++++++++++++++++++++++++++++ revalidation/script.js | 167 +++++++++++++++++++++++++++++++++++++++++ revalidation/style.css | 27 +++++++ settings/rest-api.php | 13 ++-- wporg-two-factor.php | 1 + 6 files changed, 412 insertions(+), 7 deletions(-) create mode 100644 revalidation/README.md create mode 100644 revalidation/index.php create mode 100644 revalidation/script.js create mode 100644 revalidation/style.css diff --git a/revalidation/README.md b/revalidation/README.md new file mode 100644 index 00000000..131252f9 --- /dev/null +++ b/revalidation/README.md @@ -0,0 +1,61 @@ +# Revalidation + +WordPressdotorg\Two_Factor\Revalidation provides several methods that may be used to trigger a 2FA revalidation process. + +## get_status() + +Returns the details about the current 2FA session. + + - last_validated: The UTC time that the user last completed a 2FA prompt. + - expires_at: When the users 2FA "sudo mode" revalidation period is up* (see note below). After this time, the user should be prompted for a 2FA revalidation. + - expires_save: After expires_at, 2FA save-actions may still occur, due to the save grace period. + - needs_revalidate: Whether the user should be prompted to revalidate their 2FA now. + - can_save: Whether a save operation should occur that requires 2FA validation. + +Note: The Javascript implementation does not use the same `expires_at`, instead it makes use of `expires_save` and ensures that any action that needs a 2FA session will prompt 1 minute before the `expires_save` timeframe. + +## auth_redirect( $redirect_to ) + +Allows for a save method to require 2FA status, if the request isn't 2FA'd, it'll redirect through a 2FA revalidation prompt, before coming back to your page. + +This should not be used on POST requests, as the payload will be lost, either use `get_status()` or return an error. + +## get_url( $redirect_to ) + +Returns a revalidate_2fa link, which will redirect to the specified `$redirect_to`. + +## get_js_url( $redirect_to ) + +**This is probably the function you should call.** + +Returns `get_url( $redirect_to )` but also calls `enqueue_assets()` to enqueue a JS revalidation modal that will trigger client-side to provide a better user-experience. + +## Attributes +Two Data attributes are also able to trigger 2FA revalidation modals IF `get_js_url()` has been used or `enqueue_assets()` has been called. + +### data-2fa-required +If this attribute is present, it'll trigger the 2FA modal on click, and throw the click event after completion. + +### data-2fa-message +If this attribute is present, it'll be shown in the 2FA dialogue in place of the default text. + +## Example of use. + +```php +use function WordPressdotorg\Two_Factor\Revalidation\{ + get_status as get_revalidation_status, + get_url as get_revalidation_url, + get_js_url as get_revalidation_js_url +}; + +# This is an example of a 'redirect through a 2FA revalidation screen' request. 2FA revalidation is always required. +echo '

Revalidate via redirect

'; + +# This is an example of the above, but with a JS modal instead when possible. +echo '

Revalidate via js link

'; + +# This is an example of a generic navigation or JS button that also triggers a 2FA revalidation modal. +echo '

Revalidate via data attr

'; + +``` + diff --git a/revalidation/index.php b/revalidation/index.php new file mode 100644 index 00000000..a7b5a510 --- /dev/null +++ b/revalidation/index.php @@ -0,0 +1,150 @@ + $last_validated, + 'expires_at' => $expires_at, + 'expires_save' => $expires_save, + 'needs_revalidate' => ( ! $last_validated || $expires_at < time() ), + 'can_save' => ( $expires_save > time() ), + ]; +} + +/** + * Perform a redirect to the revalidation URL if the user needs to revalidate. + * + * @param string $redirect_to The URL to redirect to after revalidating. + * @return void + */ +function auth_redirect( $redirect_to = '' ) { + $status = get_status(); + + if ( ! $status['needs_revalidate'] ) { + return; + } + + // If the user is not validated, redirect to the revalidation URL. + wp_safe_redirect( get_url( $redirect_to ) ); + exit; +} + +/** + * Get the URL for revalidating 2FA, with a redirect parameter. + * + * @param string $redirect_to The URL to redirect to after revalidating. + * @return string + */ +function get_url( $redirect_to = '' ) { + $url = Two_Factor_Core::get_user_two_factor_revalidate_url(); + if ( ! empty( $redirect_to ) ) { + $url = add_query_arg( 'redirect_to', urlencode( $redirect_to ), $url ); + } + + return $url; +} + +/** + * Get the URL for revalidating 2FA via JavaScript. + * + * The calling code can listening for a 'reValidationComplete' event, or + * simply have the user continue to $redirect_to. + * + * @param string $redirect_to The URL to redirect to after revalidating. + * @return string + */ +function get_js_url( $redirect_to = '' ) { + // Enqueue the JS to to handle the revalidate action. + enqueue_assets(); + + return get_url( $redirect_to ); +} + +/** + * Output the JavaScript & CSS for the revalidate modal. + * + * This is output to the footer of the page, and listens for clicks on revalidate links. + * When a revalidate link is clicked, a modal dialog is opened with an iframe to the revalidate 2FA session. + * When the revalidation is complete, the dialog is closed and the calling code is notified via a 'reValidationComplete' event. + */ +function enqueue_assets() { + wp_enqueue_style( 'wporg-2fa-revalidation', plugins_url( 'style.css', __FILE__ ), [], filemtime( __DIR__ . '/style.css' ) ); + wp_enqueue_script( 'wporg-2fa-revalidation', plugins_url( 'script.js', __FILE__ ), [], filemtime( __DIR__ . '/script.js' ), true ); + + wp_localize_script( 'wporg-2fa-revalidation', 'wporgTwoFactorRevalidation', [ + 'cookieName' => COOKIE_NAME, + 'l10n' => [ + 'title' => __( 'Two-Factor Authentication', 'wporg' ), + 'message' => __( 'Please verify your Two-Factor Authentication to continue.', 'wporg' ), + ], + 'url' => get_url(), + ] ); +} + +add_action( 'two_factor_user_authenticated', __NAMESPACE__ . '\set_cookie' ); +add_action( 'two_factor_user_revalidated', __NAMESPACE__ . '\set_cookie' ); +function set_cookie() { + if ( ! apply_filters( 'send_auth_cookies', true, 0, 0, 0, '', '' ) ) { + return; + } + + $expires_at = get_status()['expires_save'] ?? time(); + + /* + * Set a cookie to let JS know when the validation expires. + * + * The value is "wporg_2fa_status=TIMESTAMP", where TIMESTAMP is when the validation will expire. + * The cookie will expire a minute before the server would cease to accept the save action. + */ + setcookie( + COOKIE_NAME, + $expires_at, + $expires_at - MINUTE_IN_SECONDS, // The cookie will cease to exist to JS at this time. + COOKIEPATH, + COOKIE_DOMAIN, + is_ssl(), + false // NOT HTTP only, this needs to be JS accessible. + ); +} + +add_action( 'clear_auth_cookie', __NAMESPACE__ . '\clear_cookie' ); +function clear_cookie() { + if ( ! apply_filters( 'send_auth_cookies', true, 0, 0, 0, '', '' ) ) { + return; + } + + setcookie( COOKIE_NAME, '', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), false ); +} diff --git a/revalidation/script.js b/revalidation/script.js new file mode 100644 index 00000000..3fa11383 --- /dev/null +++ b/revalidation/script.js @@ -0,0 +1,167 @@ +window.wp = window.wp || {}; + +( function( settings, wp ) { + let revalidateModal = false; + let triggerEvent = false + + // Returns the expiry time of the sudo cookie. + const getRevalidateExpiry = function() { + const sudoCookieValue = document.cookie.split( /;\s*/ ).filter( + (cookie) => cookie.startsWith( settings.cookieName + '=' ) + )[0]?.split('=')[1] || false; + + if ( ! sudoCookieValue ) { + return false; + } + + const expiry = new Date( parseInt( sudoCookieValue ) * 1000 ); + if ( expiry < new Date() ) { + return false; + } + + return expiry; + }; + + // Whether or not revalidation is required. + const revalidateRequired = function() { + return ! getRevalidateExpiry(); + }; + + // Does the provided URL look like a revalidation url? + const urlLooksLikeRevalidationURL = function( url ) { + return url.includes( 'wp-login.php' ) && url.includes( 'action=revalidate_2fa' ); + }; + + // Display a modal dialog asking to revalidate. + const displayModal = function() { + // Remove any existing dialog from the DOM. + if ( revalidateModal ) { + revalidateModal.remove(); + } + + const triggerElement = triggerEvent?.currentTarget || triggerEvent?.target; + + revalidateModal = document.createElement( 'dialog' ); + revalidateModal.className = 'wporg-2fa-revalidate-modal'; + + const heading = document.createElement( 'h1' ); + heading.textContent = settings.l10n.title; + revalidateModal.appendChild( heading ); + + const revalidationMessage = document.createElement( 'p' ); + revalidationMessage.textContent = triggerElement?.dataset['2faMessage'] || settings.l10n.message; + revalidateModal.appendChild( revalidationMessage ); + + const linkHref = triggerElement?.href; + const iframeSrc = urlLooksLikeRevalidationURL( linkHref ) ? linkHref : settings.url; + + const iframe = document.createElement( 'iframe' ); + iframe.src = iframeSrc + '&interim-login=1'; + revalidateModal.appendChild( iframe ); + + const closeButton = document.createElement( 'button' ); + closeButton.innerHTML = ''; + closeButton.addEventListener( 'click', function() { + revalidateModal.close(); + } ); + revalidateModal.appendChild( closeButton ); + + document.body.appendChild( revalidateModal ); + + revalidateModal.showModal(); + }; + + // Remove the revalidate URL from the link, replacing it with the redirect_to if present. + const maybeRemoveRevalidateURL = function( element ) { + // If we're on a element within a link, run back up the DOM to the proper parent. + while ( element && element.tagName !== 'A' && element.parentElement ) { + element = element.parentElement; + } + + // If it's not a link, or not a valid revalidate link, bail. + if ( + ! element || + ! element.href || + ! urlLooksLikeRevalidationURL( element.href ) || + ! element.href.includes( 'redirect_to=' ) + ) { + return false; + } + + const href = new URL( element.href ); + const redirect = decodeURIComponent( href.searchParams.get( 'redirect_to' ) ); + + if ( ! redirect ) { + return false; + } + + // Overwrite. + element.href = redirect; + + return true; + }; + + // Handle the click event on a link, checking if revalidation is required prior to proceeding. + const maybeRevalidateOnLinkNavigate = function( e ) { + // Check to see if revalidation is required, otherwise we're in Sudo mode. + if ( ! revalidateRequired() ) { + maybeRemoveRevalidateURL( e.currentTarget ); + return; + } + + triggerEvent = e; + + // Prevent the default action. + e.preventDefault(); + + // If we're here, we need to revalidate the session, trigger the modal. + displayModal(); + }; + + // Wait for the revalidation to complete. + const messageHandler = function( event ) { + if ( event?.data?.type !== 'reValidationComplete' ) { + return; + } + + revalidateModal.close(); + revalidateModal.remove(); + + // Import and reset. + const theTriggerEvent = triggerEvent; + triggerEvent = false; + + // Maybe remove the revalidate URL from the last target. + if ( theTriggerEvent?.target ) { + maybeRemoveRevalidateURL( theTriggerEvent.target ); + } + + // Finally, notify others. + ( theTriggerEvent?.target || window ).dispatchEvent( new Event( 'reValidationComplete', { bubbles: true } ) ); + + // If the last event was a click, throw that again. + if ( theTriggerEvent?.type === 'click' ) { + theTriggerEvent.target.dispatchEvent( theTriggerEvent ); + } + }; + + // Export these functions for other scripts and debugging. + wp.wporg2faRevalidation = { + getRevalidateExpiry, + revalidateRequired, + urlLooksLikeRevalidationURL, + displayModal, + maybeRemoveRevalidateURL, + maybeRevalidateOnLinkNavigate, + messageHandler, + }; + + // Attach event listeners to all revalidate links and those that require 2FA sessions. + document.querySelectorAll( 'a[href*="action=revalidate_2fa"], a[data-2fa-required]' ).forEach( + (el) => el.addEventListener( 'click', maybeRevalidateOnLinkNavigate ) + ); + + // Watch for revalidation completion. + window.addEventListener( 'message', messageHandler ); + +} )( wporgTwoFactorRevalidation, window.wp ); \ No newline at end of file diff --git a/revalidation/style.css b/revalidation/style.css new file mode 100644 index 00000000..8113c0b9 --- /dev/null +++ b/revalidation/style.css @@ -0,0 +1,27 @@ +dialog.wporg-2fa-revalidate-modal { + border-radius: 8px; +} +dialog.wporg-2fa-revalidate-modal > h1 { + margin: unset; + margin-bottom: 0.5em; + text-align: center; +} +dialog.wporg-2fa-revalidate-modal > p { + font-size: 14px; + padding: 0 32px; +} +dialog.wporg-2fa-revalidate-modal > iframe { + width: 100%; + height: 330px; /* Room for errors. */ + border: none; +} +/* Close button. */ +dialog.wporg-2fa-revalidate-modal > button { + position: absolute; + top: 0; + right: 0; + background: none; + border: none; + padding: 15px; + cursor: pointer; +} \ No newline at end of file diff --git a/settings/rest-api.php b/settings/rest-api.php index dc38b55e..ee25b12b 100644 --- a/settings/rest-api.php +++ b/settings/rest-api.php @@ -302,16 +302,15 @@ function register_user_fields(): void { * when an admin is editing other users, they get prompted to update their 2FA as well. */ - $last_validated = Two_Factor_Core::is_current_user_session_two_factor(); - if ( ! $last_validated ) { + $status = get_revalidation_status(); + if ( ! $status['last_validated'] ) { return false; } - $revalidate_url = Two_Factor_Core::get_user_two_factor_revalidate_url( true ); - $expiry = apply_filters( 'two_factor_revalidate_time', 10 * MINUTE_IN_SECONDS, get_current_user_id(), '' ); - $expires_at = $last_validated + $expiry; - - return compact( 'revalidate_url', 'expires_at' ); + return [ + 'revalidate_url' => Two_Factor_Core::get_user_two_factor_revalidate_url( true ), + 'expires_at' => $status['expires_at'], + ]; }, 'schema' => [ 'type' => 'array', diff --git a/wporg-two-factor.php b/wporg-two-factor.php index a73fc2ce..ca5155ec 100644 --- a/wporg-two-factor.php +++ b/wporg-two-factor.php @@ -30,6 +30,7 @@ function is_2fa_beta_tester( $user = false ) : bool { require_once __DIR__ . '/settings/settings.php'; require_once __DIR__ . '/stats.php'; +require_once __DIR__ . '/revalidation/index.php'; /** * Load the WebAuthn plugin.