Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a revalidate modal that can be used outside of the Settings flow. #283

Merged
merged 27 commits into from
Dec 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e6fd18e
Add a revalidate modal that can be used outside of the Settings flow.…
dd32 Jul 8, 2024
c79e5d6
Move 'needs revalidation' logic into a function.
dd32 Jul 8, 2024
c27d961
rename for consistency.
dd32 Jul 8, 2024
70740d7
Document, and dispatch the event on window if no target found.
dd32 Jul 8, 2024
4dd7c60
Ensure the event bubbles.
dd32 Jul 9, 2024
c4aa169
Add some margin.
dd32 Jul 9, 2024
28a4ba9
Split to individual files.
dd32 Jul 29, 2024
6f947ae
Add additional information about the revalidation state.
dd32 Jul 29, 2024
f9b1c47
Set a cookie when a session is validated.
dd32 Jul 29, 2024
10f9e9d
Patch in a hook to two-factor. See https://github.com/WordPress/two-f…
dd32 Jul 29, 2024
844f354
Include the correct JS file.
dd32 Jul 29, 2024
15e0c95
Add an implementation of 'prompt on click' of 2fa for required actions.
dd32 Jul 29, 2024
4962117
Cleanup JS a bit
dd32 Jul 29, 2024
e6146cd
Remove workaround, merged upstream.
dd32 Jul 30, 2024
e30eac7
Have the cookie value state the expiration as well, since JS can't ac…
dd32 Aug 1, 2024
a18ea76
Export the functions for use by other scripts, simplify code and null…
dd32 Aug 1, 2024
c1d828c
Use a generic cookie name.
dd32 Sep 10, 2024
3a09bc7
Document that the cookie is not an auth cookie, just a helper for JS.
dd32 Sep 10, 2024
1c8d2e8
Clarify docs
dd32 Oct 8, 2024
fcc7a05
Merge branch 'trunk' into add/revalidate-modal
dd32 Nov 29, 2024
f4c3d0a
Namespace the revalidation methods.
dd32 Dec 5, 2024
ffd4cc5
Add a message option, to allow presenting a custom reason to do 2FA.
dd32 Dec 5, 2024
bf06879
Ensure the text is always treated as text.
dd32 Dec 5, 2024
b8b921b
Change the default message.
dd32 Dec 5, 2024
9281b90
code cleanup
dd32 Dec 5, 2024
f809f42
Add auth_redirect().
dd32 Dec 5, 2024
78b32c5
Add documentation of how to use this.
dd32 Dec 5, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading