Skip to content

Commit

Permalink
Add a revalidate modal that can be used outside of the Settings flow. (
Browse files Browse the repository at this point in the history
…#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.
  • Loading branch information
dd32 authored Dec 5, 2024
1 parent fe0a981 commit 77be0a6
Show file tree
Hide file tree
Showing 6 changed files with 412 additions and 7 deletions.
61 changes: 61 additions & 0 deletions revalidation/README.md
Original file line number Diff line number Diff line change
@@ -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 '<p><a href="' . get_revalidation_url( $_SERVER['REQUEST_URI'] ) . '">Revalidate via redirect</a></p>';

# This is an example of the above, but with a JS modal instead when possible.
echo '<p><a href="' . get_revalidation_js_url( $_SERVER['REQUEST_URI'] ) . '">Revalidate via js link</a></p>';

# This is an example of a generic navigation or JS button that also triggers a 2FA revalidation modal.
echo '<p><a href="' . esc_url( $_SERVER['REQUEST_URI'] ) . '" data-2fa-required data-2fa-message="To confirm you\'re human, please validate your Two-Factor authentication">Revalidate via data attr</a></p>';

```

150 changes: 150 additions & 0 deletions revalidation/index.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<?php
namespace WordPressdotorg\Two_Factor\Revalidation;
use Two_Factor_Core;

defined( 'WPINC' ) || die();

/**
* The name of the cookie used to store the revalidation time.
*
* This cookie is not a security cookie, it's only purpose is to flag to the JS whether
* the user session has a valid 2FA state or not.
*
* The value of the cookie may be incorrect, in which case the server will handle redirection
* to the revalidation flow.
*
* @var string
*/
const COOKIE_NAME = 'wporg_2fa_status';

/**
* Get the revalidation status for the current user, aka "sudo mode".
*
* @return array {
* @type int $last_validated The timestamp of the last time the user was validated.
* @type int $expires_at The timestamp when the current validation expires.
* @type int $expires_save The timestamp when the user will need to revalidate to save.
* @type bool $needs_revalidate Whether the user needs to revalidate.
* @type bool $can_save Whether the user can currently save.
* }
*/
function get_status() {
$last_validated = Two_Factor_Core::is_current_user_session_two_factor();
$timeout = apply_filters( 'two_factor_revalidate_time', 10 * MINUTE_IN_SECONDS, get_current_user_id(), 'display' );
$save_timeout = 2 * apply_filters( 'two_factor_revalidate_time', 10 * MINUTE_IN_SECONDS, get_current_user_id(), 'save' );
$expires_at = $last_validated + $timeout;
$expires_save = $last_validated + $save_timeout;

return [
'last_validated' => $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 );
}
167 changes: 167 additions & 0 deletions revalidation/script.js
Original file line number Diff line number Diff line change
@@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false"><path d="M13 11.8l6.1-6.3-1-1-6.1 6.2-6.1-6.2-1 1 6.1 6.3-6.5 6.7 1 1 6.5-6.6 6.5 6.6 1-1z"></path></svg>';
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 <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 );
Loading

0 comments on commit 77be0a6

Please sign in to comment.