-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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.
- Loading branch information
Showing
6 changed files
with
412 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>'; | ||
|
||
``` | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 ); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 ); |
Oops, something went wrong.