Step-by-Step Guide to building a custom two-factor authentication block for Gutenberg using Vanilla JS Web Components
Web Component Fundamentals for Gutenberg
Building a custom Gutenberg block for two-factor authentication (2FA) requires a robust understanding of modern web development practices. We’ll leverage Vanilla JavaScript Web Components to create a reusable, encapsulated UI element that can be integrated seamlessly into the WordPress block editor. This approach offers significant advantages in terms of maintainability, reusability, and separation of concerns, moving away from traditional jQuery dependencies often found in older WordPress plugins.
Our Web Component will encapsulate the logic and UI for displaying a 2FA prompt, handling user input, and communicating with a backend API for verification. This makes the component independent of the surrounding Gutenberg editor’s state and logic, simplifying development and testing.
Defining the 2FA Web Component
We’ll start by defining our custom element, `two-factor-auth-prompt`. This component will manage its internal state and render the necessary HTML elements for the 2FA code input. We’ll use the `HTMLElement` class as our base and define lifecycle callbacks like `connectedCallback` for initial setup and `attributeChangedCallback` for reacting to attribute changes.
For this example, we’ll assume a simple scenario where the component receives a `user-id` attribute to identify the user for whom 2FA is being requested. The component will internally manage the input field for the 2FA code and a submit button.
Here’s the core JavaScript for our Web Component:
Create a file named assets/js/two-factor-auth-component.js within your plugin or theme directory.
class TwoFactorAuthPrompt extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.state = {
code: '',
isSubmitting: false,
error: null
};
this.render();
}
static get observedAttributes() {
return ['user-id'];
}
connectedCallback() {
this.shadowRoot.querySelector('#auth-code').addEventListener('input', this.handleCodeInput.bind(this));
this.shadowRoot.querySelector('#submit-auth-code').addEventListener('click', this.handleSubmit.bind(this));
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'user-id' && oldValue !== newValue) {
// Potentially re-render or fetch data if needed based on user-id
console.log(`User ID changed to: ${newValue}`);
}
}
handleCodeInput(event) {
this.state.code = event.target.value;
this.setError(null); // Clear error on input
}
async handleSubmit() {
if (this.state.isSubmitting) return;
const userId = this.getAttribute('user-id');
if (!userId) {
this.setError('User ID is missing.');
return;
}
if (!this.state.code) {
this.setError('Please enter the authentication code.');
return;
}
this.state.isSubmitting = true;
this.render(); // Update UI to show loading state
try {
// Replace with your actual API endpoint
const response = await fetch('/wp-json/myplugin/v1/verify-2fa', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': this.getNonce() // Crucial for WordPress security
},
body: JSON.stringify({
user_id: userId,
code: this.state.code
})
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.message || 'Verification failed.');
}
// Dispatch a custom event on success
this.dispatchEvent(new CustomEvent('2fa-success', {
detail: { userId: userId, message: result.message }
}));
} catch (error) {
this.setError(error.message);
} finally {
this.state.isSubmitting = false;
this.render(); // Update UI to remove loading state
}
}
setError(message) {
this.state.error = message;
this.render();
}
getNonce() {
// In a real WordPress plugin, you'd pass this securely.
// For Gutenberg blocks, wp_localize_script is common.
// For a standalone web component, you might need to fetch it.
// This is a placeholder.
return document.body.dataset.wpNonce || 'fallback-nonce';
}
render() {
this.shadowRoot.innerHTML = `
${this.state.error ? `` : ''}
${this.state.isSubmitting ? 'Please wait...' : ''}
`;
}
}
customElements.define('two-factor-auth-prompt', TwoFactorAuthPrompt);
Integrating the Web Component into Gutenberg
To use this Web Component within a Gutenberg block, we need to register it and then instantiate it within our block’s `edit` and `save` functions. The `edit` function will handle the rendering in the editor, and the `save` function will determine what is rendered to the frontend. For dynamic blocks, the `save` function can return `null` if the block’s content is entirely managed by JavaScript.
First, ensure your Web Component script is enqueued properly in WordPress. This is typically done via your plugin’s main PHP file using `wp_enqueue_script`.
function my_plugin_enqueue_scripts() {
// Enqueue the Web Component script
wp_enqueue_script(
'two-factor-auth-component',
plugin_dir_url( __FILE__ ) . 'assets/js/two-factor-auth-component.js',
array(), // Dependencies
'1.0.0',
true // Load in footer
);
// Localize script to pass data like nonce
wp_localize_script(
'two-factor-auth-component',
'myPluginData',
array(
'nonce' => wp_create_nonce( 'wp_rest' ),
// Add any other data needed by the component
)
);
// Enqueue your Gutenberg block script
wp_enqueue_script(
'my-2fa-block',
plugin_dir_url( __FILE__ ) . 'assets/js/block.js', // This will contain your Gutenberg block definition
array( 'wp-blocks', 'wp-element', 'wp-editor', 'wp-components', 'wp-i18n', 'two-factor-auth-component' ),
'1.0.0',
true
);
}
add_action( 'enqueue_block_editor_assets', 'my_plugin_enqueue_scripts' );
// For frontend rendering if needed, adjust enqueue_block_assets
// add_action( 'wp_enqueue_scripts', 'my_plugin_enqueue_scripts' );
Next, let’s define the Gutenberg block itself in a file like assets/js/block.js.
const { registerBlockType } = wp.blocks;
const { InspectorControls, useBlockProps } = wp.blockEditor;
const { PanelBody, SelectControl, TextControl } = wp.components;
const { useState, useEffect } = wp.element;
const { __ } = wp.i18n;
registerBlockType('my-plugin/two-factor-auth', {
title: __('Two-Factor Authentication', 'my-plugin'),
icon: 'shield-alt', // Dashicon slug
category: 'security',
attributes: {
userId: {
type: 'string',
default: '',
},
},
edit: function(props) {
const { attributes, setAttributes } = props;
const blockProps = useBlockProps();
// Use a state variable to manage the Web Component's internal state if needed,
// or directly pass attributes.
const [wcUserId, setWcUserId] = useState(attributes.userId);
// Effect to update the Web Component's user-id attribute when block attribute changes
useEffect(() => {
const wcElement = document.querySelector('two-factor-auth-prompt[data-block-id="' + props.clientId + '"]');
if (wcElement) {
wcElement.setAttribute('user-id', attributes.userId);
}
}, [attributes.userId, props.clientId]);
// Effect to listen for the custom event from the Web Component
useEffect(() => {
const wcElement = document.querySelector('two-factor-auth-prompt[data-block-id="' + props.clientId + '"]');
if (!wcElement) return;
const handle2faSuccess = (event) => {
console.log('2FA Success from Web Component:', event.detail);
// You might want to update block attributes or trigger other actions here
// For example, hide the prompt or show a success message within the block editor.
// For simplicity, we'll just log.
alert(`2FA successful for user ${event.detail.userId}!`);
};
wcElement.addEventListener('2fa-success', handle2faSuccess);
// Cleanup listener on component unmount
return () => {
wcElement.removeEventListener('2fa-success', handle2faSuccess);
};
}, [props.clientId]);
const handleUserSelect = (value) => {
setAttributes({ userId: value });
setWcUserId(value); // Update local state for immediate feedback if needed
};
// In a real scenario, you'd fetch users from the WP REST API
const availableUsers = [
{ value: '', label: __('Select a user...', 'my-plugin') },
{ value: '1', label: __('Alice (ID: 1)', 'my-plugin') },
{ value: '2', label: __('Bob (ID: 2)', 'my-plugin') },
];
return [
// Inspector Controls for settings
!!props.isSelected && (
<InspectorControls>
<PanelBody title={__('2FA Settings', 'my-plugin')} initialOpen={true}>
<SelectControl
label={__('Target User', 'my-plugin')}
value={attributes.userId}
options={availableUsers}
onChange={handleUserSelect}
/>
<p>{__('This block will prompt the selected user for 2FA.', 'my-plugin')}</p>
</PanelBody>
</InspectorControls>
),
// Block Content
<div { ...blockProps }>
{/* Render the Web Component */}
{/* We add a data-block-id to help select the correct instance */}
<two-factor-auth-prompt
user-id={attributes.userId}
data-block-id={props.clientId}
></two-factor-auth-prompt>
{!attributes.userId && (
<p>{__('Please select a user in the block settings to enable 2FA prompt.', 'my-plugin')}</p>
)}
</div>,
];
},
save: function(props) {
const { attributes } = props;
// For dynamic blocks, return null. The frontend rendering will be handled by PHP.
// If this were a static block, you'd render the web component here,
// but dynamic is more appropriate for auth flows.
if (!attributes.userId) {
return null; // Don't render if no user is selected
}
// For a dynamic block, we return a placeholder and let PHP render the actual component.
// The PHP rendering function will receive attributes.
return null;
},
});
Dynamic Block Rendering (PHP)
Since 2FA is a dynamic process that depends on server-side logic and user context, we’ll register this as a dynamic Gutenberg block. This means the `save` function in JavaScript returns `null`, and a PHP callback function handles the actual rendering on the frontend.
The PHP rendering function will receive the block’s attributes and is responsible for outputting the HTML, including our Web Component, with the correct `user-id` attribute.
array(
'userId' => array(
'type' => 'string',
'default' => '',
),
),
'render_callback' => 'my_plugin_render_2fa_block_frontend',
) );
}
add_action( 'init', 'my_plugin_register_2fa_block' );
function my_plugin_render_2fa_block_frontend( $attributes ) {
$user_id = isset( $attributes['userId'] ) ? $attributes['userId'] : '';
if ( empty( $user_id ) ) {
return ''; // Don't render if no user is selected
}
// Enqueue the web component script on the frontend if not already done
// This is crucial if the block is only intended for frontend use or if
// enqueue_block_assets was used instead of enqueue_block_editor_assets.
// For this example, we assume it's enqueued via enqueue_block_editor_assets
// and potentially wp_enqueue_scripts if needed for frontend.
// If you need it specifically for this block on frontend:
// wp_enqueue_script('two-factor-auth-component');
// Output the web component.
// Note: The nonce is handled client-side in the web component's getNonce() method.
// For a truly secure setup, the nonce should be passed from PHP to JS via wp_localize_script
// when enqueuing the frontend script.
ob_start();
?>
<div class="my-plugin-2fa-wrapper">
<!-- The Web Component will be rendered here -->
<two-factor-auth-prompt user-id=""></two-factor-auth-prompt>
</div>
wp_create_nonce( 'wp_rest' ),
)
);
}
// Uncomment and adjust if you need the component script on the frontend independently
// add_action( 'wp_enqueue_scripts', 'my_plugin_enqueue_frontend_scripts' );
// For the REST API endpoint
function my_plugin_register_rest_routes() {
register_rest_route( 'myplugin/v1', '/verify-2fa', array(
'methods' => 'POST',
'callback' => 'my_plugin_handle_verify_2fa',
'permission_callback' => '__return_true', // Implement proper permissions
) );
}
add_action( 'rest_api_init', 'my_plugin_register_rest_routes' );
function my_plugin_handle_verify_2fa( WP_REST_Request $request ) {
$user_id = $request->get_param( 'user_id' );
$code = $request->get_param( 'code' );
// Verify nonce
if ( ! wp_verify_nonce( $request->get_header( 'X-WP-Nonce' ), 'wp_rest' ) ) {
return new WP_Error( 'rest_nonce_invalid', 'Nonce is invalid', array( 'status' => 403 ) );
}
if ( ! $user_id || ! $code ) {
return new WP_Error( 'invalid_request', 'User ID and code are required.', array( 'status' => 400 ) );
}
// --- Actual 2FA Verification Logic ---
// This is where you'd integrate with your 2FA provider (e.g., Google Authenticator, SMS, etc.)
// For demonstration, we'll simulate a successful verification.
$is_valid = false; // Replace with actual verification
if ( $code === '123456' ) { // Example hardcoded code for testing
$is_valid = true;
}
// --- End Verification Logic ---
if ( $is_valid ) {
// Optionally, log the user in or perform other actions
// wp_set_current_user( $user_id );
// wp_set_auth_cookie( $user_id );
return rest_ensure_response( array(
'success' => true,
'message' => 'Two-factor authentication successful.',
'user_id' => $user_id,
) );
} else {
return new WP_Error( 'verification_failed', 'Invalid authentication code.', array( 'status' => 400 ) );
}
}
Security Considerations and Best Practices
When implementing 2FA, security is paramount. Always:
- Use Nonces Religiously: As shown, `wp_create_nonce` and `wp_verify_nonce` are essential for securing REST API endpoints. Ensure the nonce is correctly passed from PHP to your JavaScript (via `wp_localize_script`) and then to your API requests.
- Validate and Sanitize Input: Always sanitize and validate any data received from the client-side, especially before using it in database queries or other sensitive operations.
- Secure API Endpoints: Implement proper `permission_callback` functions for your REST API routes to ensure only authorized users can access them. For 2FA verification, this might involve checking if the user is logged in or if the request is otherwise legitimate.
- Rate Limiting: Protect your `/verify-2fa` endpoint against brute-force attacks by implementing rate limiting. This can be done at the server level (e.g., Nginx) or within your PHP code.
- Error Handling: Provide generic error messages to the user to avoid leaking information about the system’s state.
- HTTPS: Ensure your entire WordPress site is served over HTTPS to protect data in transit.
- Web Component Security: While Web Components offer encapsulation, they don’t inherently provide security. The security relies on the JavaScript logic and backend validation. Be mindful of potential XSS vulnerabilities within the component’s rendering if dynamic content is inserted without proper sanitization.
The `getNonce()` method in the Web Component is a placeholder. In a production environment, you would retrieve the nonce passed via `wp_localize_script` like this:
// Inside your TwoFactorAuthPrompt class
getNonce() {
// Access the localized data
return typeof myPluginData !== 'undefined' ? myPluginData.nonce : 'fallback-nonce';
}
Conclusion
By combining the power of Vanilla JavaScript Web Components with Gutenberg’s block API and WordPress’s REST API, you can build sophisticated, reusable, and secure UI components like a custom two-factor authentication prompt. This approach promotes modern development practices, enhances maintainability, and provides a robust foundation for extending WordPress’s functionality.