From d8ebe4b681ac221124d7419781e49021bf690cb2 Mon Sep 17 00:00:00 2001 From: Sapayth Hossain Date: Wed, 29 Apr 2026 16:08:45 +0600 Subject: [PATCH 1/3] feature: 2fa with authenticator apps --- Lib/WeDevs_Settings_API.php | 106 +++++-- assets/css/admin.css | 54 ++-- assets/js/admin/settings.js | 61 ++++ assets/js/wpuf-2fa-account.js | 151 ++++++++++ assets/less/admin.less | 155 +++++----- composer.json | 7 +- composer.lock | 239 +++++++++++++++- includes/Assets.php | 5 + includes/Free/Simple_Login.php | 11 + includes/TwoFactor/Account_Section.php | 79 ++++++ includes/TwoFactor/Admin_User_Profile.php | 182 ++++++++++++ includes/TwoFactor/Crypto.php | 100 +++++++ includes/TwoFactor/Enrollment_Controller.php | 165 +++++++++++ includes/TwoFactor/Login_Controller.php | 281 +++++++++++++++++++ includes/TwoFactor/Method_Interface.php | 54 ++++ includes/TwoFactor/Method_Registry.php | 80 ++++++ includes/TwoFactor/QR_Renderer.php | 46 +++ includes/TwoFactor/Rate_Limiter.php | 72 +++++ includes/TwoFactor/TOTP_Method.php | 197 +++++++++++++ includes/TwoFactor/Two_Factor_Manager.php | 52 ++++ includes/TwoFactor/User_Storage.php | 131 +++++++++ includes/functions/settings-options.php | 30 ++ phpcs.xml.dist | 2 +- readme.txt | 2 +- templates/dashboard/security.php | 151 ++++++++++ wpuf-functions.php | 23 ++ wpuf.php | 3 +- 27 files changed, 2312 insertions(+), 127 deletions(-) create mode 100644 assets/js/wpuf-2fa-account.js create mode 100644 includes/TwoFactor/Account_Section.php create mode 100644 includes/TwoFactor/Admin_User_Profile.php create mode 100644 includes/TwoFactor/Crypto.php create mode 100644 includes/TwoFactor/Enrollment_Controller.php create mode 100644 includes/TwoFactor/Login_Controller.php create mode 100644 includes/TwoFactor/Method_Interface.php create mode 100644 includes/TwoFactor/Method_Registry.php create mode 100644 includes/TwoFactor/QR_Renderer.php create mode 100644 includes/TwoFactor/Rate_Limiter.php create mode 100644 includes/TwoFactor/TOTP_Method.php create mode 100644 includes/TwoFactor/Two_Factor_Manager.php create mode 100644 includes/TwoFactor/User_Storage.php create mode 100644 templates/dashboard/security.php diff --git a/Lib/WeDevs_Settings_API.php b/Lib/WeDevs_Settings_API.php index 48cffe45b..4596da569 100644 --- a/Lib/WeDevs_Settings_API.php +++ b/Lib/WeDevs_Settings_API.php @@ -358,9 +358,59 @@ function callback_multicheck( $args ) { * @return void */ function callback_gateway_selector( $args ) { + $this->render_card_selector( + $args, + [ + 'block' => 'wpuf-gateway-card', + 'data_attribute' => 'data-gateway', + ] + ); + } + + /** + * Displays a card grid for selecting from a generic list of methods + * + * Reuses the same card UI as `gateway_selector` but emits its own + * BEM namespace so the two selector types can evolve independently. + * Used for things like 2FA methods, social login providers, etc. + * + * @since WPUF_SINCE + * + * @param array $args settings field args + * + * @return void + */ + function callback_method_selector( $args ) { + $this->render_card_selector( + $args, + [ + 'block' => 'wpuf-method-card', + 'data_attribute' => 'data-method', + ] + ); + } + + /** + * Shared renderer for card-grid selector field types + * + * Used by `gateway_selector` and `method_selector`. The BEM block name + * and per-card data attribute come from $config so each caller emits + * its own CSS namespace and JS hook target. + * + * @since WPUF_SINCE + * + * @param array $args settings field args + * @param array $config renderer configuration: block, data_attribute + * + * @return void + */ + private function render_card_selector( $args, $config ) { $value = $this->get_option( $args['id'], $args['section'], $args['std'] ); $value = $value ? $value : []; + $block = $config['block']; + $data_attribute = $config['data_attribute']; + // Inline SVG fallback icons (no image files exist for these) $bank_svg = ''; $generic_svg = ''; @@ -368,40 +418,40 @@ function callback_gateway_selector( $args ) {
-
- $gateway ) : +
+ $option ) : $is_checked = in_array( $key, $value, true ); - $is_pro = ! empty( $gateway['is_pro_preview'] ) && $gateway['is_pro_preview']; + $is_pro = ! empty( $option['is_pro_preview'] ) && $option['is_pro_preview']; $disabled = $is_pro ? 'disabled' : ''; - $active_class = $is_checked ? ' wpuf-gateway-card--active' : ''; - $pro_class = $is_pro ? ' wpuf-gateway-card--pro-locked' : ''; - $icon = ! empty( $gateway['icon'] ) ? $gateway['icon'] : ''; - $admin_label = $gateway['admin_label']; + $active_class = $is_checked ? ' ' . $block . '--active' : ''; + $pro_class = $is_pro ? ' ' . $block . '--pro-locked' : ''; + $icon = ! empty( $option['icon'] ) ? $option['icon'] : ''; + $admin_label = $option['admin_label']; ?> -
+
=""> /> - -
+
<?php echo esc_attr( $admin_label ); ?> @@ -411,7 +461,7 @@ class="wpuf-gateway-card__checkbox"
-
+
@@ -998,22 +1048,22 @@ function(){ for (var field_name in dependencies) { var expected_value = dependencies[field_name]; var $depends_on = $("input[id*='"+ field_name +"'], select[id*='"+ field_name +"']"); - + // If no field found with the simple selector, try more specific selectors if ($depends_on.length === 0) { $depends_on = $("input[name*='["+ field_name +"]'], select[name*='["+ field_name +"]']"); } - + // If still no field found, try looking for the field name in the ID attribute if ($depends_on.length === 0) { $depends_on = $("input[id*='["+ field_name +"]'], select[id*='["+ field_name +"]']"); } - + // If still no field found, try looking for the field name anywhere in the ID if ($depends_on.length === 0) { $depends_on = $("input[id*='"+ field_name +"'], select[id*='"+ field_name +"']"); } - + if ($depends_on.length === 0) { all_dependencies_met = false; break; @@ -1023,7 +1073,7 @@ function(){ var current_value = $depends_on.val(); var is_checkbox = $depends_on.attr('type') === 'checkbox'; var is_checked = is_checkbox ? $depends_on.is(':checked') : null; - + // For checkboxes, handle empty string as "checked" expectation var value_matches = false; if (is_checkbox) { @@ -1037,7 +1087,7 @@ function(){ } else { value_matches = current_value === expected_value; } - + if (!value_matches) { all_dependencies_met = false; } @@ -1073,26 +1123,26 @@ function checkAllDependencies() { for (var field_name in dependencies) { var expected_value = dependencies[field_name]; var $depends_on = $("input[id*='"+ field_name +"'], select[id*='"+ field_name +"']"); - + // If no field found with the simple selector, try more specific selectors if ($depends_on.length === 0) { $depends_on = $("input[name*='["+ field_name +"]'], select[name*='["+ field_name +"]']"); } - + // If still no field found, try looking for the field name in the ID attribute if ($depends_on.length === 0) { $depends_on = $("input[id*='["+ field_name +"]'], select[id*='["+ field_name +"]']"); } - + // If still no field found, try looking for the field name anywhere in the ID if ($depends_on.length === 0) { $depends_on = $("input[id*='"+ field_name +"'], select[id*='"+ field_name +"']"); } - + var current_value = $depends_on.length > 0 ? $depends_on.val() : 'NOT_FOUND'; var is_checkbox = $depends_on.length > 0 && $depends_on.attr('type') === 'checkbox'; var is_checked = is_checkbox ? $depends_on.is(':checked') : null; - + // For checkboxes, handle empty string as "checked" expectation var value_matches = false; if ($depends_on.length === 0) { @@ -1108,13 +1158,13 @@ function checkAllDependencies() { } else { value_matches = current_value === expected_value; } - + if (!value_matches) { all_met = false; break; } } - + if (all_met) { $this.closest('tr').show(); } else { diff --git a/assets/css/admin.css b/assets/css/admin.css index 7c1b94ca3..e054bc6ad 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -1502,12 +1502,14 @@ body.wpuf-modal-open { .wpuf-login-settings-row.hidden { display: none !important; } -.wpuf-gateway-cards { +.wpuf-gateway-cards, +.wpuf-method-cards { display: flex; flex-wrap: wrap; gap: 12px; } -.wpuf-gateway-card { +.wpuf-gateway-card, +.wpuf-method-card { position: relative; display: flex; flex-direction: column; @@ -1521,32 +1523,39 @@ body.wpuf-modal-open { cursor: pointer; transition: border-color 0.15s ease, box-shadow 0.15s ease; } -.wpuf-gateway-card:hover { +.wpuf-gateway-card:hover, +.wpuf-method-card:hover { border-color: #c3c4c7; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); } -.wpuf-gateway-card--active:hover { +.wpuf-gateway-card--active:hover, +.wpuf-method-card--active:hover { border-color: #10b981; } -.wpuf-gateway-card--focused { +.wpuf-gateway-card--focused, +.wpuf-method-card--focused { border-color: #10b981; box-shadow: 0 0 0 1px #10b981; } -.wpuf-gateway-card--focused:hover { +.wpuf-gateway-card--focused:hover, +.wpuf-method-card--focused:hover { border-color: #10b981; } -.wpuf-gateway-card--pro-locked { +.wpuf-gateway-card--pro-locked, +.wpuf-method-card--pro-locked { opacity: 0.6; cursor: not-allowed; } -.wpuf-gateway-card__checkbox { +.wpuf-gateway-card__checkbox, +.wpuf-method-card__checkbox { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; } -.wpuf-gateway-card__toggle { +.wpuf-gateway-card__toggle, +.wpuf-method-card__toggle { position: absolute; top: 4px; right: 4px; @@ -1555,22 +1564,28 @@ body.wpuf-modal-open { padding: 4px; z-index: 2; } -.wpuf-gateway-card__toggle:hover { +.wpuf-gateway-card__toggle:hover, +.wpuf-method-card__toggle:hover { opacity: 0.8; } -.wpuf-gateway-card__check-on { +.wpuf-gateway-card__check-on, +.wpuf-method-card__check-on { display: none; } -.wpuf-gateway-card__check-off { +.wpuf-gateway-card__check-off, +.wpuf-method-card__check-off { display: block; } -.wpuf-gateway-card--active .wpuf-gateway-card__check-on { +.wpuf-gateway-card--active .wpuf-gateway-card__check-on, +.wpuf-method-card--active .wpuf-method-card__check-on { display: block; } -.wpuf-gateway-card--active .wpuf-gateway-card__check-off { +.wpuf-gateway-card--active .wpuf-gateway-card__check-off, +.wpuf-method-card--active .wpuf-method-card__check-off { display: none; } -.wpuf-gateway-card__icon { +.wpuf-gateway-card__icon, +.wpuf-method-card__icon { display: flex; align-items: center; justify-content: center; @@ -1578,16 +1593,19 @@ body.wpuf-modal-open { height: 48px; margin-bottom: 8px; } -.wpuf-gateway-card__icon img { +.wpuf-gateway-card__icon img, +.wpuf-method-card__icon img { max-width: 100px; max-height: 48px; object-fit: contain; } -.wpuf-gateway-card__icon svg { +.wpuf-gateway-card__icon svg, +.wpuf-method-card__icon svg { max-width: 48px; max-height: 48px; } -.wpuf-gateway-card__name { +.wpuf-gateway-card__name, +.wpuf-method-card__name { font-size: 13px; font-weight: 500; color: #1d2327; diff --git a/assets/js/admin/settings.js b/assets/js/admin/settings.js index 964533b21..534bb7424 100644 --- a/assets/js/admin/settings.js +++ b/assets/js/admin/settings.js @@ -62,6 +62,9 @@ // Gateway Selector Card Grid wpufInitGatewaySelector(); + // Method Selector Card Grid (2FA, social login, etc.) + wpufInitMethodSelector(); + function wpuf_search_reset() { content.forEach(function (row, index) { var content_id = row.closest('div').getAttribute('id'); @@ -304,4 +307,62 @@ hideAllGatewaySettings(); } } + + /** + * Method Selector Card Grid + * + * Generic toggle handler for the `method_selector` field type. + * Unlike the gateway selector, methods do not have per-method + * settings rows to reveal — clicking a card simply flips the + * checkbox and the `--active` class. Pro-locked cards are inert. + */ + function wpufInitMethodSelector() { + var containers = document.querySelectorAll('.wpuf-method-cards'); + + if ( ! containers.length ) { + return; + } + + containers.forEach(function(container) { + var cards = container.querySelectorAll('.wpuf-method-card'); + + cards.forEach(function(card) { + var checkbox = card.querySelector('.wpuf-method-card__checkbox'); + + if ( ! checkbox ) { + return; + } + + // Sync `--active` when the checkbox state changes (label click, + // keyboard, or programmatic toggle). + checkbox.addEventListener('change', function() { + if ( checkbox.checked ) { + card.classList.add('wpuf-method-card--active'); + } else { + card.classList.remove('wpuf-method-card--active'); + } + }); + + // Card body click toggles the checkbox. The toggle label has its + // own `for` binding so we skip clicks that originate inside it + // to avoid double-firing. + card.addEventListener('click', function(e) { + if ( e.target.closest('.wpuf-method-card__toggle') ) { + return; + } + + if ( e.target === checkbox ) { + return; + } + + if ( card.classList.contains('wpuf-method-card--pro-locked') ) { + return; + } + + checkbox.checked = ! checkbox.checked; + checkbox.dispatchEvent(new Event('change', { bubbles: true })); + }); + }); + }); + } })(); diff --git a/assets/js/wpuf-2fa-account.js b/assets/js/wpuf-2fa-account.js new file mode 100644 index 000000000..8b9237a1b --- /dev/null +++ b/assets/js/wpuf-2fa-account.js @@ -0,0 +1,151 @@ +/** + * WPUF Two-Factor Authentication — Account Security tab + * + * Drives the enrollment, confirmation, and self-disable flows on the + * [wpuf_account] Security tab. Talks to the AJAX endpoints registered + * by Enrollment_Controller. + */ +(function ($) { + 'use strict'; + + $(function () { + var $root = $('#wpuf-2fa-security'); + + if (!$root.length) { + return; + } + + var ajaxUrl = $root.data('ajax-url'); + var nonce = $root.data('nonce'); + + function showMessage($container, message, isError) { + var $msg = $container.find('.wpuf-2fa-message').first(); + $msg.text(message); + $msg.removeClass('wpuf-text-red-600 wpuf-text-green-700'); + $msg.addClass(isError ? 'wpuf-text-red-600' : 'wpuf-text-green-700'); + } + + // --- Enrollment: start --------------------------------------------- + $root.on('click', '.wpuf-2fa-totp-start', function (e) { + e.preventDefault(); + var $btn = $(this); + $btn.prop('disabled', true); + + $.post(ajaxUrl, { + action: 'wpuf_2fa_totp_start', + _wpnonce: nonce + }).done(function (response) { + if (!response || !response.success) { + var msg = response && response.data && response.data.message + ? response.data.message + : 'Could not start setup.'; + showMessage($root.find('.wpuf-2fa-totp-enrollment'), msg, true); + $btn.prop('disabled', false); + return; + } + + var data = response.data; + var $enroll = $root.find('.wpuf-2fa-totp-enrollment'); + var $qr = $enroll.find('.wpuf-2fa-qr-target'); + var $secretIn = $enroll.find('.wpuf-2fa-secret-display'); + + $qr.html(data.qr_svg || ''); + $secretIn.val(data.secret || ''); + $enroll.removeClass('wpuf-hidden'); + $btn.prop('disabled', true).text( + $btn.data('done-label') || 'Set up in progress…' + ); + $enroll.find('.wpuf-2fa-totp-code').focus(); + }).fail(function () { + showMessage($root.find('.wpuf-2fa-totp-enrollment'), 'Network error. Please try again.', true); + $btn.prop('disabled', false); + }); + }); + + // --- Enrollment: confirm ------------------------------------------- + $root.on('click', '.wpuf-2fa-totp-confirm', function (e) { + e.preventDefault(); + var $btn = $(this); + var $enroll = $root.find('.wpuf-2fa-totp-enrollment'); + var code = $enroll.find('.wpuf-2fa-totp-code').val(); + + if (!code || code.length !== 6) { + showMessage($enroll, 'Enter the 6-digit code from your app.', true); + return; + } + + $btn.prop('disabled', true); + + $.post(ajaxUrl, { + action: 'wpuf_2fa_totp_confirm', + _wpnonce: nonce, + code: code + }).done(function (response) { + if (!response || !response.success) { + var msg = response && response.data && response.data.message + ? response.data.message + : 'Could not verify the code.'; + showMessage($enroll, msg, true); + $btn.prop('disabled', false); + + if (response && response.data && response.data.expired) { + // Pending transient gone — make the user start over. + $enroll.addClass('wpuf-hidden'); + $root.find('.wpuf-2fa-totp-start').prop('disabled', false); + } + return; + } + + showMessage($enroll, response.data.message, false); + // Reload so the tab re-renders into the enrolled state. + window.setTimeout(function () { window.location.reload(); }, 800); + }).fail(function () { + showMessage($enroll, 'Network error. Please try again.', true); + $btn.prop('disabled', false); + }); + }); + + // --- Disable ------------------------------------------------------- + $root.on('click', '.wpuf-2fa-disable-submit', function (e) { + e.preventDefault(); + var $btn = $(this); + var $form = $root.find('.wpuf-2fa-disable-form'); + var password = $form.find('.wpuf-2fa-disable-password').val(); + var code = $form.find('.wpuf-2fa-disable-code').val(); + + if (!password) { + showMessage($form, 'Enter your current password.', true); + return; + } + if (!code || code.length !== 6) { + showMessage($form, 'Enter the 6-digit code from your app.', true); + return; + } + + $btn.prop('disabled', true); + + $.post(ajaxUrl, { + action: 'wpuf_2fa_totp_disable', + _wpnonce: nonce, + password: password, + code: code + }).done(function (response) { + if (!response || !response.success) { + var msg = response && response.data && response.data.message + ? response.data.message + : 'Could not disable 2FA.'; + showMessage($form, msg, true); + $btn.prop('disabled', false); + return; + } + + showMessage($form, response.data.message, false); + window.setTimeout(function () { window.location.reload(); }, 800); + }).fail(function () { + showMessage($form, 'Network error. Please try again.', true); + $btn.prop('disabled', false); + }); + }); + }); + +})(jQuery); diff --git a/assets/less/admin.less b/assets/less/admin.less index c6970462e..e7d0e7f17 100644 --- a/assets/less/admin.less +++ b/assets/less/admin.less @@ -1536,16 +1536,22 @@ body.wpuf-modal-open { } // ========================================================================== -// Gateway Selector Card Grid +// Card Selector Grid // ========================================================================== +// Shared by `gateway_selector` and `method_selector` field types. Each +// emits its own BEM block (`wpuf-gateway-card`, `wpuf-method-card`) so +// the two namespaces evolve independently while reusing the same rules. +// Add new selector blocks to both selector lists below. -.wpuf-gateway-cards { +.wpuf-gateway-cards, +.wpuf-method-cards { display: flex; flex-wrap: wrap; gap: 12px; } -.wpuf-gateway-card { +.wpuf-gateway-card, +.wpuf-method-card { position: relative; display: flex; flex-direction: column; @@ -1563,92 +1569,103 @@ body.wpuf-modal-open { border-color: #c3c4c7; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); } +} - &--active:hover { - border-color: #10b981; - } +.wpuf-gateway-card--active:hover, +.wpuf-method-card--active:hover { + border-color: #10b981; +} - &--focused { - border-color: #10b981; - box-shadow: 0 0 0 1px #10b981; +.wpuf-gateway-card--focused, +.wpuf-method-card--focused { + border-color: #10b981; + box-shadow: 0 0 0 1px #10b981; - &:hover { - border-color: #10b981; - } - } - - &--pro-locked { - opacity: 0.6; - cursor: not-allowed; + &:hover { + border-color: #10b981; } +} - &__checkbox { - position: absolute; - opacity: 0; - width: 0; - height: 0; - pointer-events: none; - } +.wpuf-gateway-card--pro-locked, +.wpuf-method-card--pro-locked { + opacity: 0.6; + cursor: not-allowed; +} - &__toggle { - position: absolute; - top: 4px; - right: 4px; - cursor: pointer; - line-height: 1; - padding: 4px; - z-index: 2; +.wpuf-gateway-card__checkbox, +.wpuf-method-card__checkbox { + position: absolute; + opacity: 0; + width: 0; + height: 0; + pointer-events: none; +} - &:hover { - opacity: 0.8; - } - } +.wpuf-gateway-card__toggle, +.wpuf-method-card__toggle { + position: absolute; + top: 4px; + right: 4px; + cursor: pointer; + line-height: 1; + padding: 4px; + z-index: 2; - &__check-on { - display: none; + &:hover { + opacity: 0.8; } +} - &__check-off { - display: block; - } +.wpuf-gateway-card__check-on, +.wpuf-method-card__check-on { + display: none; +} - &--active &__check-on { - display: block; - } +.wpuf-gateway-card__check-off, +.wpuf-method-card__check-off { + display: block; +} - &--active &__check-off { - display: none; - } +.wpuf-gateway-card--active .wpuf-gateway-card__check-on, +.wpuf-method-card--active .wpuf-method-card__check-on { + display: block; +} - &__icon { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 48px; - margin-bottom: 8px; +.wpuf-gateway-card--active .wpuf-gateway-card__check-off, +.wpuf-method-card--active .wpuf-method-card__check-off { + display: none; +} - img { - max-width: 100px; - max-height: 48px; - object-fit: contain; - } +.wpuf-gateway-card__icon, +.wpuf-method-card__icon { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 48px; + margin-bottom: 8px; - svg { - max-width: 48px; - max-height: 48px; - } + img { + max-width: 100px; + max-height: 48px; + object-fit: contain; } - &__name { - font-size: 13px; - font-weight: 500; - color: #1d2327; - text-align: center; - line-height: 1.3; + svg { + max-width: 48px; + max-height: 48px; } } +.wpuf-gateway-card__name, +.wpuf-method-card__name { + font-size: 13px; + font-weight: 500; + color: #1d2327; + text-align: center; + line-height: 1.3; +} + // Gateway settings rows shown/hidden by JS tr.wpuf-gateway-setting-row { transition: opacity 0.15s ease; diff --git a/composer.json b/composer.json index 76241e547..1a18c87a6 100644 --- a/composer.json +++ b/composer.json @@ -10,10 +10,13 @@ } ], "minimum-stability": "dev", + "prefer-stable": true, "require": { - "php": ">=5.5", + "php": ">=7.4", "composer/installers": ">=1.4", - "wedevs/wp-utils": "dev-main" + "wedevs/wp-utils": "dev-main", + "pragmarx/google2fa": "^8.0.3", + "bacon/bacon-qr-code": "^2.0" }, "require-dev": { "wp-coding-standards/wpcs": "dev-develop", diff --git a/composer.lock b/composer.lock index cc79b25cc..139cf6585 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,62 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1fa30eae591844494a2c8a0926763809", + "content-hash": "1c82807f56b1fbdaa4d9dc598dc052e8", "packages": [ + { + "name": "bacon/bacon-qr-code", + "version": "2.0.8", + "source": { + "type": "git", + "url": "https://github.com/Bacon/BaconQrCode.git", + "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22", + "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22", + "shasum": "" + }, + "require": { + "dasprid/enum": "^1.0.3", + "ext-iconv": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phly/keep-a-changelog": "^2.1", + "phpunit/phpunit": "^7 | ^8 | ^9", + "spatie/phpunit-snapshot-assertions": "^4.2.9", + "squizlabs/php_codesniffer": "^3.4" + }, + "suggest": { + "ext-imagick": "to generate QR code images" + }, + "type": "library", + "autoload": { + "psr-4": { + "BaconQrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "BaconQrCode is a QR code generator for PHP.", + "homepage": "https://github.com/Bacon/BaconQrCode", + "support": { + "issues": "https://github.com/Bacon/BaconQrCode/issues", + "source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8" + }, + "time": "2022-12-07T17:46:57+00:00" + }, { "name": "composer/installers", "version": "dev-main", @@ -152,6 +206,177 @@ ], "time": "2024-11-26T13:24:01+00:00" }, + { + "name": "dasprid/enum", + "version": "1.0.7", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "shasum": "" + }, + "require": { + "php": ">=7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" + }, + "time": "2025-09-16T12:23:56+00:00" + }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "pragmarx/google2fa", + "version": "v8.0.3", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa.git", + "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad", + "reference": "6f8d87ebd5afbf7790bde1ffc7579c7c705e0fad", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^7.5.15|^8.5|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PragmaRX\\Google2FA\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "A One Time Password Authentication package, compatible with Google Authenticator.", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa/issues", + "source": "https://github.com/antonioribeiro/google2fa/tree/v8.0.3" + }, + "time": "2024-09-05T11:56:40+00:00" + }, { "name": "wedevs/wp-utils", "version": "dev-main", @@ -2588,16 +2813,16 @@ "aliases": [], "minimum-stability": "dev", "stability-flags": { - "wedevs/wp-utils": 20, - "wp-coding-standards/wpcs": 20, + "phpcompatibility/phpcompatibility-wp": 20, "tareq1988/wp-php-cs-fixer": 20, - "phpcompatibility/phpcompatibility-wp": 20 + "wedevs/wp-utils": 20, + "wp-coding-standards/wpcs": 20 }, - "prefer-stable": false, + "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": ">=5.5" + "php": ">=7.4" }, - "platform-dev": [], + "platform-dev": {}, "plugin-api-version": "2.6.0" } diff --git a/includes/Assets.php b/includes/Assets.php index 278972d3f..a5b09aeb7 100644 --- a/includes/Assets.php +++ b/includes/Assets.php @@ -422,6 +422,11 @@ public function get_scripts() { 'src' => WPUF_ASSET_URI . '/js/elementor-subscription-plans.js', 'in_footer' => true, ], + '2fa-account' => [ + 'src' => WPUF_ASSET_URI . '/js/wpuf-2fa-account.js', + 'deps' => [ 'jquery' ], + 'in_footer' => true, + ], ]; if ( ! empty( $api_key ) ) { diff --git a/includes/Free/Simple_Login.php b/includes/Free/Simple_Login.php index 59a18a474..7e2cceb28 100644 --- a/includes/Free/Simple_Login.php +++ b/includes/Free/Simple_Login.php @@ -504,6 +504,17 @@ public function process_login() { return; } + // 2FA challenge submission. Stage 2 carries only the verification + // code + token — credentials are empty (already verified in stage 1) + // and Turnstile is not re-rendered on the challenge view. Skip so + // we don't stack "Username is required" / "Password is required" / + // "Cloudflare Turnstile verification failed" alongside the actual + // 2FA error. The 2FA controller owns this request from `init`@9. + // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( ! empty( $_POST['wpuf_2fa_token'] ) ) { + return; + } + // unset the specific cookie to fix WooCommerce Stripe Gateway plugin conflict add_action( 'set_logged_in_cookie', [ $this, 'unset_logged_in_cookie' ], 11 ); diff --git a/includes/TwoFactor/Account_Section.php b/includes/TwoFactor/Account_Section.php new file mode 100644 index 000000000..5b6eb99e5 --- /dev/null +++ b/includes/TwoFactor/Account_Section.php @@ -0,0 +1,79 @@ +totp = $totp; + $this->storage = $storage; + + add_filter( 'wpuf_account_sections', [ $this, 'register_section' ] ); + add_action( 'wpuf_account_content_' . self::SECTION_ID, [ $this, 'render_section' ], 10, 2 ); + } + + public function register_section( $sections ) { + if ( ! $this->is_2fa_enabled_globally() ) { + return $sections; + } + + if ( ! is_array( $sections ) ) { + $sections = (array) $sections; + } + + $sections[ self::SECTION_ID ] = __( 'Security', 'wp-user-frontend' ); + + return $sections; + } + + public function render_section( $sections, $current_section ) { + if ( ! $this->is_2fa_enabled_globally() ) { + return; + } + + $user_id = get_current_user_id(); + $is_enrolled = $this->totp->is_enrolled( $user_id ); + + wp_enqueue_script( 'wpuf-2fa-account' ); + + wpuf_load_template( + 'dashboard/security.php', + [ + 'sections' => $sections, + 'current_section' => $current_section, + 'is_enrolled' => $is_enrolled, + 'enrolled_at' => $is_enrolled ? $this->storage->get_totp_enrolled_at( $user_id ) : null, + 'totp_label' => $this->totp->get_label(), + 'nonce' => wp_create_nonce( Enrollment_Controller::NONCE_ACTION ), + 'ajax_url' => admin_url( 'admin-ajax.php' ), + ] + ); + } + + private function is_2fa_enabled_globally() { + if ( wpuf_get_option( 'enable_2fa', 'wpuf_2fa', 'off' ) !== 'on' ) { + return false; + } + + $active = wpuf_get_option( 'active_2fa_methods', 'wpuf_2fa', [] ); + + return is_array( $active ) && in_array( TOTP_Method::ID, $active, true ); + } +} diff --git a/includes/TwoFactor/Admin_User_Profile.php b/includes/TwoFactor/Admin_User_Profile.php new file mode 100644 index 000000000..874d7dc36 --- /dev/null +++ b/includes/TwoFactor/Admin_User_Profile.php @@ -0,0 +1,182 @@ +totp = $totp; + $this->storage = $storage; + + add_action( 'show_user_profile', [ $this, 'render_section' ], 35 ); + add_action( 'edit_user_profile', [ $this, 'render_section' ], 35 ); + add_action( 'admin_post_' . self::ACTION, [ $this, 'handle_reset' ] ); + } + + public function render_section( $user ) { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + $is_enrolled = $this->totp->is_enrolled( $user->ID ); + $enrolled_at = $is_enrolled ? $this->storage->get_totp_enrolled_at( $user->ID ) : null; + $audit_log = $this->storage->get_audit_log( $user->ID ); + ?> +

+ + + + + + + + + + + +
+ +

+ +

+ . HTML doesn't allow + // nested forms — a literal
here gets flattened + // by the browser, so the click ends up POSTing the + // outer profile form to user-edit.php (which then + // shows "The link you followed has expired"). + // Instead: render a plain button and have JS build + // and submit a real top-level form on click. + $reset_action = esc_attr( self::ACTION ); + $reset_url = esc_url( admin_url( 'admin-post.php' ) ); + $reset_nonce = esc_attr( wp_create_nonce( self::ACTION . '_' . $user->ID ) ); + $reset_user_id = (int) $user->ID; + $reset_confirm = esc_attr( __( 'This will allow this user to log in without 2FA. They will need to re-enroll if they want to use 2FA again.', 'wp-user-frontend' ) ); + ?> + + + +

+ +
+
    + +
  • + action_label( $entry['action'] ) ), + esc_html( $actor ? $actor->user_login : __( 'unknown', 'wp-user-frontend' ) ), + esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $entry['timestamp'] ) ) + ); + ?> +
  • + +
+
+ totp->reset( $target_user_id ); + $this->storage->append_audit( $target_user_id, 'admin_reset', get_current_user_id() ); + + do_action( 'wpuf_2fa_totp_reset', $target_user_id, get_current_user_id() ); + + wp_safe_redirect( add_query_arg( 'wpuf_2fa_reset', '1', get_edit_user_link( $target_user_id ) ) ); + exit; + } + + private function action_label( $action ) { + switch ( $action ) { + case 'enrolled': + return __( 'Enrolled', 'wp-user-frontend' ); + case 'self_disable': + return __( 'Self-disabled', 'wp-user-frontend' ); + case 'admin_reset': + return __( 'Admin reset', 'wp-user-frontend' ); + default: + return $action; + } + } +} diff --git a/includes/TwoFactor/Crypto.php b/includes/TwoFactor/Crypto.php new file mode 100644 index 000000000..dec3accf3 --- /dev/null +++ b/includes/TwoFactor/Crypto.php @@ -0,0 +1,100 @@ +derive_key(), + OPENSSL_RAW_DATA, + $iv, + $tag + ); + + // base64 here is binary-to-text encoding for storage, not obfuscation. + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + return self::STORAGE_VERSION + . '.' . base64_encode( $iv ) // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + . '.' . base64_encode( $tag ) // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + . '.' . base64_encode( $ciphertext ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + } + + /** + * Decrypt a value produced by encrypt(). Returns null on failure + * (corrupted, key rotated, or version unsupported). + * + * @param string $payload + * + * @return string|null + */ + public function decrypt( $payload ) { + if ( ! is_string( $payload ) || $payload === '' ) { + return null; + } + + $parts = explode( '.', $payload ); + + if ( count( $parts ) !== 4 || $parts[0] !== self::STORAGE_VERSION ) { + return null; + } + + // base64 here is binary-to-text decoding for storage, not obfuscation. + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + $iv = base64_decode( $parts[1], true ); + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + $tag = base64_decode( $parts[2], true ); + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + $ciphertext = base64_decode( $parts[3], true ); + + if ( $iv === false || $tag === false || $ciphertext === false ) { + return null; + } + + $plaintext = openssl_decrypt( + $ciphertext, + self::CIPHER, + $this->derive_key(), + OPENSSL_RAW_DATA, + $iv, + $tag + ); + + return $plaintext === false ? null : $plaintext; + } + + /** + * Derive a 32-byte key from AUTH_KEY using HKDF-SHA256. + * + * Falls back to wp_salt() if AUTH_KEY is not defined (default WP installs + * always define it; this guard is for malformed configs). + */ + private function derive_key() { + $material = defined( 'AUTH_KEY' ) && AUTH_KEY ? AUTH_KEY : wp_salt( 'auth' ); + + return hash_hkdf( 'sha256', $material, 32, 'wpuf-2fa-secret-v1', '' ); + } +} diff --git a/includes/TwoFactor/Enrollment_Controller.php b/includes/TwoFactor/Enrollment_Controller.php new file mode 100644 index 000000000..6aa0eb61f --- /dev/null +++ b/includes/TwoFactor/Enrollment_Controller.php @@ -0,0 +1,165 @@ +totp = $totp; + $this->storage = $storage; + $this->qr = $qr; + + add_action( 'wp_ajax_wpuf_2fa_totp_start', [ $this, 'ajax_start' ] ); + add_action( 'wp_ajax_wpuf_2fa_totp_confirm', [ $this, 'ajax_confirm' ] ); + add_action( 'wp_ajax_wpuf_2fa_totp_disable', [ $this, 'ajax_disable' ] ); + } + + /** + * Start (or restart) enrollment. Generates a fresh secret, parks it in + * the pending transient, returns secret + QR for the UI. + */ + public function ajax_start() { + $user_id = $this->require_logged_in_user(); + $this->verify_nonce(); + + if ( ! $this->is_method_active() ) { + wp_send_json_error( [ 'message' => __( 'Authenticator app method is not enabled.', 'wp-user-frontend' ) ] ); + } + + $secret = $this->totp->generate_secret(); + $this->storage->set_pending_totp( $user_id, $secret ); + + $user = get_userdata( $user_id ); + $uri = $this->totp->build_otpauth_uri( $secret, $user->user_email ); + + wp_send_json_success( + [ + 'secret' => $secret, + 'otpauth_uri' => $uri, + 'qr_svg' => $this->qr->render_svg( $uri ), + ] + ); + } + + /** + * Confirm enrollment with a code. On success, the pending secret + * becomes active in a single user-meta write. + */ + public function ajax_confirm() { + $user_id = $this->require_logged_in_user(); + $this->verify_nonce(); + + // Nonce verified above in verify_nonce(); phpcs cannot trace it. + // phpcs:ignore WordPress.Security.NonceVerification.Missing + $code = isset( $_POST['code'] ) ? sanitize_text_field( wp_unslash( $_POST['code'] ) ) : ''; + + $pending = $this->storage->get_pending_totp( $user_id ); + + if ( $pending === null ) { + wp_send_json_error( + [ + 'message' => __( 'Your setup session has expired. Please start again.', 'wp-user-frontend' ), + 'expired' => true, + ] + ); + } + + if ( ! $this->totp->verify_against_secret( $user_id, $pending, $code ) ) { + wp_send_json_error( [ 'message' => __( "That code didn't match. Try again.", 'wp-user-frontend' ) ] ); + } + + // Atomic swap: pending becomes active, transient cleared. + $this->storage->set_totp_secret( $user_id, $pending ); + $this->storage->clear_pending_totp( $user_id ); + $this->storage->append_audit( $user_id, 'enrolled', $user_id ); + + do_action( 'wpuf_2fa_totp_enrolled', $user_id ); + + wp_send_json_success( + [ + 'message' => __( 'Two-factor authentication is now active on your account.', 'wp-user-frontend' ), + ] + ); + } + + /** + * Self-disable. Requires current password + current valid TOTP code. + */ + public function ajax_disable() { + $user_id = $this->require_logged_in_user(); + $this->verify_nonce(); + + // Passwords must not be sanitized — they're verified against a + // hash and can contain any byte. Nonce verified above. + // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $password = isset( $_POST['password'] ) ? (string) wp_unslash( $_POST['password'] ) : ''; + // phpcs:ignore WordPress.Security.NonceVerification.Missing + $code = isset( $_POST['code'] ) ? sanitize_text_field( wp_unslash( $_POST['code'] ) ) : ''; + + $user = get_userdata( $user_id ); + + if ( ! $password || ! wp_check_password( $password, $user->user_pass, $user_id ) ) { + wp_send_json_error( [ 'message' => __( 'Your password is incorrect.', 'wp-user-frontend' ) ] ); + } + + if ( ! $this->totp->verify( $user_id, $code ) ) { + wp_send_json_error( [ 'message' => __( "That code didn't match. Try again.", 'wp-user-frontend' ) ] ); + } + + $this->totp->reset( $user_id ); + $this->storage->append_audit( $user_id, 'self_disable', $user_id ); + + do_action( 'wpuf_2fa_totp_disabled', $user_id, 'self' ); + + wp_send_json_success( + [ + 'message' => __( 'Two-factor authentication has been disabled.', 'wp-user-frontend' ), + ] + ); + } + + private function require_logged_in_user() { + if ( ! is_user_logged_in() ) { + wp_send_json_error( [ 'message' => __( 'You must be logged in.', 'wp-user-frontend' ) ], 403 ); + } + + return get_current_user_id(); + } + + private function verify_nonce() { + $nonce = isset( $_POST['_wpnonce'] ) ? sanitize_key( wp_unslash( $_POST['_wpnonce'] ) ) : ''; + + if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) { + wp_send_json_error( [ 'message' => __( 'Security check failed. Please refresh the page and try again.', 'wp-user-frontend' ) ], 403 ); + } + } + + private function is_method_active() { + if ( wpuf_get_option( 'enable_2fa', 'wpuf_2fa', 'off' ) !== 'on' ) { + return false; + } + + $active = wpuf_get_option( 'active_2fa_methods', 'wpuf_2fa', [] ); + + return is_array( $active ) && in_array( TOTP_Method::ID, $active, true ); + } +} diff --git a/includes/TwoFactor/Login_Controller.php b/includes/TwoFactor/Login_Controller.php new file mode 100644 index 000000000..9d7d1d25f --- /dev/null +++ b/includes/TwoFactor/Login_Controller.php @@ -0,0 +1,281 @@ +registry = $registry; + $this->totp = $totp; + + add_action( 'init', [ $this, 'maybe_handle_stage_two' ], 9 ); + add_filter( 'authenticate', [ $this, 'maybe_require_totp_challenge' ], 40, 3 ); + add_action( 'wpuf_login_form_bottom', [ $this, 'render_2fa_field' ] ); + } + + /** + * Stage 2: catches the submission with token + code. Runs at init + * priority 9, just before Simple_Login::process_login (priority 10). + * + * Stage-2 POSTs only carry the code + token — `log` and `pwd` are + * empty (the form's hidden credential fields submit blank) and the + * Turnstile widget isn't re-rendered for the challenge view. + * `Simple_Login::process_login` short-circuits when it sees + * `$_POST['wpuf_2fa_token']`, so it won't pollute the error pipeline + * with "Username is required" / "Cloudflare Turnstile failed". + */ + public function maybe_handle_stage_two() { + // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( empty( $_POST['wpuf_2fa_token'] ) || empty( $_POST['wpuf_login'] ) ) { + return; + } + + $nonce = isset( $_POST['wpuf-login-nonce'] ) ? sanitize_key( wp_unslash( $_POST['wpuf-login-nonce'] ) ) : ''; + + if ( ! wp_verify_nonce( $nonce, 'wpuf_login_action' ) ) { + // Fail closed with our own message. Simple_Login is gated by + // the wpuf_2fa_token presence guard, so it won't surface its + // own "Nonce is invalid" message on top of this one. + $this->pending_token = ''; + $this->push_login_error( __( 'Security check failed. Please log in again.', 'wp-user-frontend' ) ); + + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Missing + $token = sanitize_text_field( wp_unslash( $_POST['wpuf_2fa_token'] ) ); + // phpcs:ignore WordPress.Security.NonceVerification.Missing + $code = isset( $_POST['wpuf_2fa_code'] ) ? sanitize_text_field( wp_unslash( $_POST['wpuf_2fa_code'] ) ) : ''; + + $challenge = $this->load_challenge( $token ); + + if ( ! $challenge ) { + // Expired. The user must re-enter credentials to mint a new + // token, so clear the pending flag and let the form re-render + // its username/password fields. + $this->pending_token = ''; + $this->push_login_error( __( 'Your sign-in session expired. Please log in again.', 'wp-user-frontend' ) ); + + return; + } + + $user_id = (int) $challenge['user_id']; + + if ( ! $this->totp->verify( $user_id, $code ) ) { + // Wrong code — keep the same token so the user can retry + // without re-entering credentials. + $this->pending_token = $token; + $this->push_login_error( __( "That code didn't match. Try again.", 'wp-user-frontend' ) ); + + return; + } + + // Success: consume token, set the auth cookie, redirect. + delete_transient( self::TRANSIENT_KEY . $token ); + + $remember = ! empty( $challenge['remember'] ); + wp_set_auth_cookie( $user_id, $remember ); + + $redirect = ! empty( $challenge['final_redirect'] ) + ? $challenge['final_redirect'] + : home_url( '/' ); + + wp_safe_redirect( $redirect ); + exit; + } + + /** + * Stage 1: hook into the standard `authenticate` chain. If the user + * has TOTP enrolled, mint a token and return a WP_Error to abort + * the signon. The user has been authenticated correctly up to this + * point — the error is purely a "we need one more step" signal. + * + * @param \WP_User|\WP_Error|null $user + * @param string $username + * @param string $password + * + * @return \WP_User|\WP_Error|null + */ + // $username and $password are required by the `authenticate` filter + // signature even though we read credentials from the validated user. + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + public function maybe_require_totp_challenge( $user, $username, $password ) { + // Scope strictly to WPUF login form submissions. + // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( empty( $_POST['wpuf_login'] ) ) { + return $user; + } + + // If a token is present we're in stage 2, which is handled by + // maybe_handle_stage_two() at init priority 9; by the time + // wp_signon runs (later) the redirect/exit has already happened. + // This branch is defense-in-depth only. + // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( ! empty( $_POST['wpuf_2fa_token'] ) ) { + return $user; + } + + // Pass-through if WP core or another plugin already failed auth. + if ( is_wp_error( $user ) || ! is_a( $user, '\WP_User' ) ) { + return $user; + } + + if ( ! $this->is_2fa_enabled_globally() ) { + return $user; + } + + if ( ! $this->totp->is_enrolled( $user->ID ) ) { + return $user; + } + + // Credentials are valid; mint a challenge token and require code. + // phpcs:ignore WordPress.Security.NonceVerification.Missing + $remember = isset( $_POST['rememberme'] ) ? (bool) $_POST['rememberme'] : false; + // phpcs:ignore WordPress.Security.NonceVerification.Missing + $final_redirect = isset( $_POST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_POST['redirect_to'] ) ) : home_url( '/' ); + + $token = wp_generate_password( 32, false, false ); + + set_transient( + self::TRANSIENT_KEY . $token, + [ + 'user_id' => $user->ID, + 'remember' => $remember, + 'final_redirect' => $final_redirect, + ], + self::TOKEN_TTL + ); + + $this->pending_token = $token; + + return new \WP_Error( + 'wpuf_2fa_required', + __( 'Enter the 6-digit code from your authenticator app to finish signing in.', 'wp-user-frontend' ) + ); + } + + /** + * Render the 2FA code input + token inside the WPUF login form. + * Only renders when a challenge is pending for this request — kept + * invisible until stage 1 succeeds. + */ + public function render_2fa_field() { + if ( ! $this->pending_token ) { + return; + } + ?> +
+

+ + +

+ +
+ + + methods[ $method->get_id() ] = $method; + } + + /** + * @return Method_Interface[] + */ + public function all() { + return $this->methods; + } + + /** + * @param string $id + * + * @return Method_Interface|null + */ + public function get( $id ) { + return isset( $this->methods[ $id ] ) ? $this->methods[ $id ] : null; + } + + /** + * Methods that the admin has enabled in Global Settings (active_2fa_methods). + * + * @return Method_Interface[] + */ + public function active() { + $active_ids = wpuf_get_option( 'active_2fa_methods', 'wpuf_2fa', [] ); + + if ( ! is_array( $active_ids ) ) { + $active_ids = []; + } + + $active = []; + foreach ( $active_ids as $id ) { + if ( isset( $this->methods[ $id ] ) ) { + $active[ $id ] = $this->methods[ $id ]; + } + } + + return $active; + } + + /** + * Methods this user has actually completed enrollment for. + * + * @param int $user_id + * + * @return Method_Interface[] + */ + public function enrolled_for( $user_id ) { + $enrolled = []; + foreach ( $this->methods as $id => $method ) { + if ( $method->is_enrolled( $user_id ) ) { + $enrolled[ $id ] = $method; + } + } + + return $enrolled; + } +} diff --git a/includes/TwoFactor/QR_Renderer.php b/includes/TwoFactor/QR_Renderer.php new file mode 100644 index 000000000..f57427e21 --- /dev/null +++ b/includes/TwoFactor/QR_Renderer.php @@ -0,0 +1,46 @@ +writeString( $otpauth_uri ); + } catch ( \Throwable $e ) { + return ''; + } + } +} diff --git a/includes/TwoFactor/Rate_Limiter.php b/includes/TwoFactor/Rate_Limiter.php new file mode 100644 index 000000000..55d07c8e7 --- /dev/null +++ b/includes/TwoFactor/Rate_Limiter.php @@ -0,0 +1,72 @@ + time() ) { + return true; + } + + if ( $until && $until <= time() ) { + // Lockout expired — clear so the next attempt starts fresh. + $this->clear( $user_id ); + } + + return false; + } + + /** + * Record a failed attempt. Engages the lockout when MAX_FAILURES is hit. + * + * @param int $user_id + * + * @return void + */ + public function record_failure( $user_id ) { + $count = (int) get_user_meta( $user_id, self::META_FAILURES, true ); + ++$count; + + update_user_meta( $user_id, self::META_FAILURES, $count ); + + if ( $count >= self::MAX_FAILURES ) { + update_user_meta( $user_id, self::META_LOCKED, time() + self::LOCKOUT_WINDOW ); + } + } + + /** + * Clear failure count and any active lockout. Called on success or + * admin reset. + * + * @param int $user_id + * + * @return void + */ + public function clear( $user_id ) { + delete_user_meta( $user_id, self::META_FAILURES ); + delete_user_meta( $user_id, self::META_LOCKED ); + } +} diff --git a/includes/TwoFactor/TOTP_Method.php b/includes/TwoFactor/TOTP_Method.php new file mode 100644 index 000000000..c78b8fe64 --- /dev/null +++ b/includes/TwoFactor/TOTP_Method.php @@ -0,0 +1,197 @@ +google2fa = $google2fa; + $this->storage = $storage; + $this->rate_limiter = $rate_limiter; + + $this->google2fa->setWindow( self::DRIFT_WINDOW_STEPS ); + + // The library's "Google Authenticator compatibility" check is too + // strict — it occasionally rejects its OWN random secrets even + // though Google Authenticator accepts them fine. Disabling the + // check is what every production integration does. Verified safe + // because the secret length and base32 alphabet are still validated. + $this->google2fa->setEnforceGoogleAuthenticatorCompatibility( false ); + } + + public function get_id() { + return self::ID; + } + + public function get_label() { + return __( 'Authenticator App', 'wp-user-frontend' ); + } + + public function is_enrolled( $user_id ) { + return $this->storage->get_totp_secret( $user_id ) !== null; + } + + /** + * Generate a fresh base32 secret. Used for new enrollment and + * regeneration. + * + * @return string + */ + public function generate_secret() { + return $this->google2fa->generateSecretKey( self::SECRET_LENGTH_BYTES ); + } + + /** + * Build the otpauth:// URI for QR-code rendering. + * + * @param string $secret Base32 secret. + * @param string $username Email or username shown in the authenticator app. + * + * @return string + */ + public function build_otpauth_uri( $secret, $username ) { + $issuer = wpuf_get_option( 'totp_issuer_label', 'wpuf_2fa', get_bloginfo( 'name' ) ); + + return $this->google2fa->getQRCodeUrl( + $issuer, + $username, + $secret + ); + } + + /** + * Verify a code against the user's *active* secret. Honors rate limit + * and replay protection. Use verify_against_secret() for enrollment + * confirmation where the secret is still pending. + * + * @param int $user_id + * @param string $code + * + * @return bool + */ + public function verify( $user_id, $code ) { + if ( $this->rate_limiter->is_locked( $user_id ) ) { + return false; + } + + $secret = $this->storage->get_totp_secret( $user_id ); + + if ( $secret === null ) { + return false; + } + + $accepted_step = $this->verify_step( $secret, $code ); + + if ( $accepted_step === null ) { + $this->rate_limiter->record_failure( $user_id ); + + return false; + } + + // Replay protection: reject codes whose step has already been used. + $last = $this->storage->get_last_accepted_step( $user_id ); + + if ( $accepted_step <= $last ) { + $this->rate_limiter->record_failure( $user_id ); + + return false; + } + + $this->storage->set_last_accepted_step( $user_id, $accepted_step ); + $this->rate_limiter->clear( $user_id ); + + return true; + } + + /** + * Verify a code against an arbitrary secret. Used during enrollment + * confirmation where the candidate secret is in the pending transient, + * not user meta. Still honors the rate limiter. + * + * @param int $user_id + * @param string $secret + * @param string $code + * + * @return bool + */ + public function verify_against_secret( $user_id, $secret, $code ) { + if ( $this->rate_limiter->is_locked( $user_id ) ) { + return false; + } + + $accepted_step = $this->verify_step( $secret, $code ); + + if ( $accepted_step === null ) { + $this->rate_limiter->record_failure( $user_id ); + + return false; + } + + $this->rate_limiter->clear( $user_id ); + + return true; + } + + public function reset( $user_id ) { + $this->storage->clear_totp( $user_id ); + $this->rate_limiter->clear( $user_id ); + } + + /** + * Run RFC 6238 verification across the drift window. + * + * Uses verifyKeyNewer() rather than verifyKey() so we get the matching + * step number (int) back instead of just bool. Replay enforcement + * lives in our own User_Storage::get_last_accepted_step() check, not + * here — passing oldTimestamp = -1 disables the library's own replay + * gate so it always returns the matched step on a hit. + * + * @param string $secret + * @param string $code + * + * @return int|null Step number that accepted the code, or null on miss. + */ + private function verify_step( $secret, $code ) { + $code = preg_replace( '/\s+/', '', (string) $code ); + + if ( $code === '' || \strlen( $code ) !== 6 ) { + return null; + } + + $accepted = $this->google2fa->verifyKeyNewer( $secret, $code, -1 ); + + if ( $accepted === false ) { + return null; + } + + return (int) $accepted; + } +} diff --git a/includes/TwoFactor/Two_Factor_Manager.php b/includes/TwoFactor/Two_Factor_Manager.php new file mode 100644 index 000000000..6ca4c8650 --- /dev/null +++ b/includes/TwoFactor/Two_Factor_Manager.php @@ -0,0 +1,52 @@ +two_factor` from the rest of the plugin. Free ships TOTP only; + * Pro can register additional methods (Email/SMS OTP) by extending this + * manager or hooking the `wpuf_2fa_register_methods` action. + * + * @since WPUF_SINCE + */ +class Two_Factor_Manager { + use ContainerTrait; + + public function __construct() { + $crypto = new Crypto(); + $storage = new User_Storage( $crypto ); + $rate_limiter = new Rate_Limiter(); + $google2fa = new Google2FA(); + $totp = new TOTP_Method( $google2fa, $storage, $rate_limiter ); + $qr = new QR_Renderer(); + $registry = new Method_Registry(); + + $registry->register( $totp ); + + $this->container['crypto'] = $crypto; + $this->container['storage'] = $storage; + $this->container['rate_limiter'] = $rate_limiter; + $this->container['totp'] = $totp; + $this->container['qr'] = $qr; + $this->container['registry'] = $registry; + + $this->container['enrollment'] = new Enrollment_Controller( $totp, $storage, $qr ); + $this->container['login'] = new Login_Controller( $registry, $totp ); + $this->container['account'] = new Account_Section( $totp, $storage ); + + // Admin_User_Profile registers `show_user_profile` / `edit_user_profile` + // hooks; instantiate only in admin context so those hooks aren't + // attached on front-end requests. + if ( is_admin() ) { + $this->container['admin_user'] = new Admin_User_Profile( $totp, $storage ); + } + + do_action( 'wpuf_2fa_register_methods', $registry ); + } +} diff --git a/includes/TwoFactor/User_Storage.php b/includes/TwoFactor/User_Storage.php new file mode 100644 index 000000000..8a6c637dc --- /dev/null +++ b/includes/TwoFactor/User_Storage.php @@ -0,0 +1,131 @@ +crypto = $crypto; + } + + public function set_totp_secret( $user_id, $plaintext_secret ) { + update_user_meta( $user_id, self::META_TOTP_SECRET, $this->crypto->encrypt( $plaintext_secret ) ); + update_user_meta( $user_id, self::META_TOTP_ENROLLED, time() ); + } + + /** + * @return string|null Plaintext base32 secret, or null if not enrolled + * (or AUTH_KEY rotated and the ciphertext is now garbage). + */ + public function get_totp_secret( $user_id ) { + $payload = get_user_meta( $user_id, self::META_TOTP_SECRET, true ); + + if ( ! $payload ) { + return null; + } + + return $this->crypto->decrypt( $payload ); + } + + public function clear_totp( $user_id ) { + delete_user_meta( $user_id, self::META_TOTP_SECRET ); + delete_user_meta( $user_id, self::META_TOTP_ENROLLED ); + delete_user_meta( $user_id, self::META_TOTP_LAST_STEP ); + $this->clear_pending_totp( $user_id ); + } + + public function get_totp_enrolled_at( $user_id ) { + $ts = (int) get_user_meta( $user_id, self::META_TOTP_ENROLLED, true ); + + return $ts ? $ts : null; + } + + /** + * Last accepted RFC 6238 step. Replay protection: any submitted code + * whose step is <= this value is rejected. + */ + public function get_last_accepted_step( $user_id ) { + return (int) get_user_meta( $user_id, self::META_TOTP_LAST_STEP, true ); + } + + public function set_last_accepted_step( $user_id, $step ) { + update_user_meta( $user_id, self::META_TOTP_LAST_STEP, (int) $step ); + } + + /** + * Pending secret lives in a transient, keyed by user ID. + * The active secret remains valid until the pending one is confirmed. + */ + public function set_pending_totp( $user_id, $plaintext_secret ) { + set_transient( + self::TRANSIENT_PENDING . $user_id, + $this->crypto->encrypt( $plaintext_secret ), + self::PENDING_TTL + ); + } + + public function get_pending_totp( $user_id ) { + $payload = get_transient( self::TRANSIENT_PENDING . $user_id ); + + if ( ! $payload ) { + return null; + } + + return $this->crypto->decrypt( $payload ); + } + + public function clear_pending_totp( $user_id ) { + delete_transient( self::TRANSIENT_PENDING . $user_id ); + } + + /** + * Append an audit entry. Rolling list capped at AUDIT_LIMIT entries. + * + * @param int $user_id Subject of the action. + * @param string $action e.g. 'admin_reset', 'self_disable', 'enrolled'. + * @param int $actor_id User ID that performed the action. + */ + public function append_audit( $user_id, $action, $actor_id ) { + $log = $this->get_audit_log( $user_id ); + $log[] = [ + 'action' => $action, + 'actor_id' => (int) $actor_id, + 'timestamp' => time(), + ]; + + if ( count( $log ) > self::AUDIT_LIMIT ) { + $log = array_slice( $log, -self::AUDIT_LIMIT ); + } + + update_user_meta( $user_id, self::META_AUDIT_LOG, $log ); + } + + /** + * @return array + */ + public function get_audit_log( $user_id ) { + $log = get_user_meta( $user_id, self::META_AUDIT_LOG, true ); + + return is_array( $log ) ? $log : []; + } +} diff --git a/includes/functions/settings-options.php b/includes/functions/settings-options.php index 9c3434782..dd305bd59 100644 --- a/includes/functions/settings-options.php +++ b/includes/functions/settings-options.php @@ -34,6 +34,11 @@ function wpuf_settings_sections() { 'title' => __( 'Login / Registration', 'wp-user-frontend' ), 'icon' => 'dashicons-admin-users', ], + [ + 'id' => 'wpuf_2fa', + 'title' => __( '2FA', 'wp-user-frontend' ), + 'icon' => 'dashicons-id', + ], [ 'id' => 'wpuf_payment', 'title' => __( 'Payments', 'wp-user-frontend' ), @@ -663,6 +668,31 @@ function wpuf_settings_fields() { 'is_pro_preview' => ! wpuf_is_pro_active(), ], ] ), + 'wpuf_2fa' => apply_filters( 'wpuf_options_2fa', [ + [ + 'name' => 'enable_2fa', + 'label' => __( 'Enable Two-Factor Authentication', 'wp-user-frontend' ), + 'desc' => __( 'When enabled, users can secure their accounts with a second verification step.', 'wp-user-frontend' ), + 'type' => 'toggle', + 'default' => 'off', + ], + [ + 'name' => 'active_2fa_methods', + 'label' => __( '2FA Methods', 'wp-user-frontend' ), + 'desc' => __( 'Select which methods users can use to verify their identity.', 'wp-user-frontend' ), + 'type' => 'method_selector', + 'options' => wpuf_get_2fa_methods(), + 'depends_on' => 'enable_2fa', + ], + [ + 'name' => 'totp_issuer_label', + 'label' => __( 'App Display Name', 'wp-user-frontend' ), + 'desc' => __( "Name shown inside the user's authenticator app (e.g., Google Authenticator).", 'wp-user-frontend' ), + 'type' => 'text', + 'default' => get_bloginfo( 'name' ), + 'depends_on' => 'enable_2fa', + ], + ] ), 'wpuf_payment' => apply_filters( 'wpuf_options_payment', [ [ 'name' => 'enable_payment', diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 47eaee550..f018190fe 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -34,7 +34,7 @@ - + diff --git a/readme.txt b/readme.txt index 71c4c11a9..429001945 100644 --- a/readme.txt +++ b/readme.txt @@ -5,7 +5,7 @@ Tags: frontend post, user directory, membership, user profile, user registration Requires at least: 5.0 Tested up to: 6.9.4 Stable tag: 4.3.2 -Requires PHP: 5.6 +Requires PHP: 7.4 License: GPLv2 License URL: https://www.gnu.org/licenses/gpl-2.0.html diff --git a/templates/dashboard/security.php b/templates/dashboard/security.php new file mode 100644 index 000000000..91b363155 --- /dev/null +++ b/templates/dashboard/security.php @@ -0,0 +1,151 @@ + +
+ +
+

+ +

+

+ +

+

+ +

+
+ + + +
+
+
+

+ +

+

+ +

+
+ +
+ +
+

+ +

+ +
+
+
+ + +
+
+ + + + +
+ +
+ +

+
+
+ + + +
+
+

+ +

+ + + +
+

+ +

+ +
+

+ +

+ +
+
+ + +
+
+ + +
+
+ + + +

+
+
+ + +
diff --git a/wpuf-functions.php b/wpuf-functions.php index 7aa1fa051..0d9d97149 100644 --- a/wpuf-functions.php +++ b/wpuf-functions.php @@ -949,6 +949,29 @@ function wpuf_get_gateways( $context = 'admin' ) { return $return; } +/** + * Get all the registered 2FA methods + * + * Each method is shaped like a gateway-selector card so the shared card + * renderer can render them. Email OTP and SMS OTP ship as pro previews + * for now — only TOTP is functional in the free MVP. + * + * @since WPUF_SINCE + * + * @return array + */ +function wpuf_get_2fa_methods() { + $methods = [ + 'totp' => [ + 'admin_label' => __( 'Authenticator App', 'wp-user-frontend' ), + 'icon' => '', + 'is_pro_preview' => false, + ], + ]; + + return apply_filters( 'wpuf_2fa_methods', $methods ); +} + /** * Show custom fields in post content area * diff --git a/wpuf.php b/wpuf.php index b5f9f3b6d..4df675997 100644 --- a/wpuf.php +++ b/wpuf.php @@ -57,7 +57,7 @@ final class WP_User_Frontend { * * @var string */ - private $min_php = '5.6'; + private $min_php = '7.4'; /** * Holds various class instances @@ -190,6 +190,7 @@ public function instantiate() { $this->container['api'] = new WeDevs\Wpuf\API(); $this->container['integrations'] = new WeDevs\Wpuf\Integrations(); $this->container['ai_manager'] = new WeDevs\Wpuf\AI_Manager(); + $this->container['two_factor'] = new WeDevs\Wpuf\TwoFactor\Two_Factor_Manager(); if ( is_admin() ) { $this->container['admin'] = new WeDevs\Wpuf\Admin(); From e97886439b27d454b9bf8100ee4df138cd930e57 Mon Sep 17 00:00:00 2001 From: Sapayth Hossain Date: Thu, 30 Apr 2026 15:49:31 +0600 Subject: [PATCH 2/3] feature: 2fa + login audit cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2FA framework: TOTP authenticator support, multi-method registry, enrollment / challenge / verify flow, security dashboard section, and extracted login-stage CSS/JS to dedicated wpuf-2fa-login assets. Login audit (docs/login-process-audit.md, Phases 1-3): - Reconciled the two Turnstile settings on `login_form_turnstile`. - Removed dead Turnstile error string and the `form_align` dead pipeline. - New filters: `wpuf_login_turnstile_verify`, `wpuf_login_form_layout_class`, `wpuf_login_layouts`, `wpuf_login_form_template_args`. - New actions: `wpuf_login_show_errors`, `wpuf_login_show_messages`. - Layout class applied server-side (no FOUC); single source of truth for the layout list. - Server-side rendering of customizable labels / placeholders / button / remember-me / lost-password / form heading — fixes a11y, page caches, and silent shortcode-page heading. --- assets/css/wpuf-2fa-login.css | 75 +++ assets/js/wpuf-2fa-account.js | 188 +++++-- assets/js/wpuf-2fa-login.js | 46 ++ includes/Assets.php | 7 + includes/Free/Simple_Login.php | 102 +++- includes/TwoFactor/Account_Section.php | 29 +- includes/TwoFactor/Admin_User_Profile.php | 101 ++-- includes/TwoFactor/Default_Card_Renderer.php | 188 +++++++ includes/TwoFactor/Enrollment_Controller.php | 244 ++++++--- includes/TwoFactor/Login_Controller.php | 515 ++++++++++++++++--- includes/TwoFactor/Method_Interface.php | 117 ++++- includes/TwoFactor/TOTP_Method.php | 219 +++++--- includes/TwoFactor/Two_Factor_Manager.php | 49 +- includes/TwoFactor/User_Storage.php | 270 ++++++++-- templates/dashboard/security.php | 239 +++++---- templates/login-form.php | 68 ++- wpuf-functions.php | 51 +- 17 files changed, 1969 insertions(+), 539 deletions(-) create mode 100644 assets/css/wpuf-2fa-login.css create mode 100644 assets/js/wpuf-2fa-login.js create mode 100644 includes/TwoFactor/Default_Card_Renderer.php diff --git a/assets/css/wpuf-2fa-login.css b/assets/css/wpuf-2fa-login.css new file mode 100644 index 000000000..7cf464fe9 --- /dev/null +++ b/assets/css/wpuf-2fa-login.css @@ -0,0 +1,75 @@ +/* + * WPUF — 2FA login challenge styles + * + * Loaded conditionally by Login_Controller::render_2fa_field() only when + * a 2FA challenge or method-picker stage is being rendered inside the + * [wpuf-login] form. + * + * Two concerns: + * 1. Hide the credentials row + remember-me + submit button while a + * challenge / picker is in flight. The form still posts to itself; + * stage 2 reads the hidden token, the credentials are no longer + * needed. + * 2. Self-contained method-picker styles. Themes vary wildly in how + * they style #loginform inputs/labels, so visibility and layout + * are locked with !important to keep radios + labels legible on + * any theme. + */ + +/* ---- 1. Hide credential row during challenge / picker ---- */ + +#loginform .wpuf-login-form > p:has(#wpuf-user_login), +#loginform .wpuf-login-form > p:has(#wpuf-user_pass), +#loginform > p:has(#wpuf-user_login), +#loginform > p:has(#wpuf-user_pass), +#loginform p.submit, +.wpuf-login-form .forgetmenot { + display: none !important; +} + +/* ---- 2. Method-picker layout ---- */ + +.wpuf-2fa-method-picker { + display: block !important; + margin: 1em 0 !important; +} + +.wpuf-2fa-method-picker__option { + display: block !important; + margin-bottom: .75em !important; +} + +.wpuf-2fa-method-picker__option label { + display: inline-flex !important; + align-items: center !important; + gap: .5em !important; + color: inherit !important; + cursor: pointer !important; +} + +.wpuf-2fa-method-picker__radio { + display: inline-block !important; + width: auto !important; + height: auto !important; + margin: 0 !important; + opacity: 1 !important; + position: static !important; + visibility: visible !important; +} + +.wpuf-2fa-method-picker__label { + display: inline !important; + color: inherit !important; + font-weight: 600 !important; +} + +.wpuf-2fa-method-picker__hint { + display: block !important; + color: #666 !important; + font-size: .9em !important; + margin: .15em 0 0 1.85em !important; +} + +.wpuf-2fa-method-picker__actions { + margin-top: 1em !important; +} diff --git a/assets/js/wpuf-2fa-account.js b/assets/js/wpuf-2fa-account.js index 8b9237a1b..9d240c09b 100644 --- a/assets/js/wpuf-2fa-account.js +++ b/assets/js/wpuf-2fa-account.js @@ -1,9 +1,18 @@ /** * WPUF Two-Factor Authentication — Account Security tab * - * Drives the enrollment, confirmation, and self-disable flows on the - * [wpuf_account] Security tab. Talks to the AJAX endpoints registered - * by Enrollment_Controller. + * Drives enrollment, confirmation, code-issuance, and self-disable flows + * on the [wpuf_account] Security tab. Generic across methods: every + * card has a data-method-id attribute and the four AJAX actions take + * method_id as a parameter. Method-specific extras (TOTP QR code, + * Email OTP masked destination, etc.) come from the props returned by + * Method_Interface::start_enrollment() and are rendered client-side + * via the renderEnrollmentExtras() dispatcher below. + * + * Methods that need a fresh code before self-disable (Email OTP, SMS + * OTP) are detected via the presence of `.wpuf-2fa-issue-disable-code` + * being unhidden by the renderer; TOTP keeps it hidden because the + * code is always available in the user's authenticator app. */ (function ($) { 'use strict'; @@ -20,84 +29,142 @@ function showMessage($container, message, isError) { var $msg = $container.find('.wpuf-2fa-message').first(); - $msg.text(message); + $msg.text(message || ''); $msg.removeClass('wpuf-text-red-600 wpuf-text-green-700'); $msg.addClass(isError ? 'wpuf-text-red-600' : 'wpuf-text-green-700'); } - // --- Enrollment: start --------------------------------------------- - $root.on('click', '.wpuf-2fa-totp-start', function (e) { + function methodIdFor($card) { + return $card.data('method-id') || $card.attr('data-method-id') || ''; + } + + // --- Enrollment extras renderers ---------------------------------- + // Per-method renderers fill `.wpuf-2fa-enrollment-extras` based on + // whatever start_enrollment() returned. Each method gets one + // function; default falls back to a generic instructions blurb. + var extrasRenderers = { + totp: function ($extras, props) { + var html = ''; + html += '
'; + html += '
' + (props.qr_svg || '') + '
'; + html += '
'; + html += ''; + html += '/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function escapeAttr(s) { + return escapeHtml(s); + } + + // --- Enrollment: start -------------------------------------------- + $root.on('click', '.wpuf-2fa-method-start', function (e) { e.preventDefault(); - var $btn = $(this); + var $btn = $(this); + var $card = $btn.closest('.wpuf-2fa-method-card'); + var methodId = methodIdFor($card); + var $enroll = $card.find('.wpuf-2fa-method-enrollment'); + $btn.prop('disabled', true); $.post(ajaxUrl, { - action: 'wpuf_2fa_totp_start', - _wpnonce: nonce + action: 'wpuf_2fa_method_start', + _wpnonce: nonce, + method_id: methodId }).done(function (response) { if (!response || !response.success) { - var msg = response && response.data && response.data.message + var msg = (response && response.data && response.data.message) ? response.data.message : 'Could not start setup.'; - showMessage($root.find('.wpuf-2fa-totp-enrollment'), msg, true); + showMessage($enroll, msg, true); $btn.prop('disabled', false); return; } - var data = response.data; - var $enroll = $root.find('.wpuf-2fa-totp-enrollment'); - var $qr = $enroll.find('.wpuf-2fa-qr-target'); - var $secretIn = $enroll.find('.wpuf-2fa-secret-display'); + var $extras = $enroll.find('.wpuf-2fa-enrollment-extras'); + renderEnrollmentExtras(methodId, $extras, response.data); + + if (response.data && response.data.note) { + $enroll.find('.wpuf-2fa-enrollment-instructions').text(response.data.note); + } else { + $enroll.find('.wpuf-2fa-enrollment-instructions').empty(); + } - $qr.html(data.qr_svg || ''); - $secretIn.val(data.secret || ''); $enroll.removeClass('wpuf-hidden'); - $btn.prop('disabled', true).text( - $btn.data('done-label') || 'Set up in progress…' - ); - $enroll.find('.wpuf-2fa-totp-code').focus(); + $enroll.find('.wpuf-2fa-method-code').focus(); }).fail(function () { - showMessage($root.find('.wpuf-2fa-totp-enrollment'), 'Network error. Please try again.', true); + showMessage($enroll, 'Network error. Please try again.', true); $btn.prop('disabled', false); }); }); - // --- Enrollment: confirm ------------------------------------------- - $root.on('click', '.wpuf-2fa-totp-confirm', function (e) { + // --- Enrollment: confirm ------------------------------------------ + $root.on('click', '.wpuf-2fa-method-confirm', function (e) { e.preventDefault(); - var $btn = $(this); - var $enroll = $root.find('.wpuf-2fa-totp-enrollment'); - var code = $enroll.find('.wpuf-2fa-totp-code').val(); - - if (!code || code.length !== 6) { - showMessage($enroll, 'Enter the 6-digit code from your app.', true); + var $btn = $(this); + var $card = $btn.closest('.wpuf-2fa-method-card'); + var methodId = methodIdFor($card); + var $enroll = $card.find('.wpuf-2fa-method-enrollment'); + var code = $enroll.find('.wpuf-2fa-method-code').val(); + + if (!code) { + showMessage($enroll, 'Enter the verification code.', true); return; } $btn.prop('disabled', true); $.post(ajaxUrl, { - action: 'wpuf_2fa_totp_confirm', + action: 'wpuf_2fa_method_confirm', _wpnonce: nonce, + method_id: methodId, code: code }).done(function (response) { if (!response || !response.success) { - var msg = response && response.data && response.data.message + var msg = (response && response.data && response.data.message) ? response.data.message : 'Could not verify the code.'; showMessage($enroll, msg, true); $btn.prop('disabled', false); if (response && response.data && response.data.expired) { - // Pending transient gone — make the user start over. + // Pending state expired — bounce back to the start. $enroll.addClass('wpuf-hidden'); - $root.find('.wpuf-2fa-totp-start').prop('disabled', false); + $card.find('.wpuf-2fa-method-start').prop('disabled', false); } return; } showMessage($enroll, response.data.message, false); - // Reload so the tab re-renders into the enrolled state. window.setTimeout(function () { window.location.reload(); }, 800); }).fail(function () { showMessage($enroll, 'Network error. Please try again.', true); @@ -105,33 +172,66 @@ }); }); + // --- Issue a disable code (Email OTP, SMS OTP) -------------------- + $root.on('click', '.wpuf-2fa-issue-disable-code', function (e) { + e.preventDefault(); + var $btn = $(this); + var $card = $btn.closest('.wpuf-2fa-method-card'); + var methodId = methodIdFor($card); + var $form = $card.find('.wpuf-2fa-disable-form'); + + $btn.prop('disabled', true); + + $.post(ajaxUrl, { + action: 'wpuf_2fa_method_issue', + _wpnonce: nonce, + method_id: methodId + }).done(function (response) { + $btn.prop('disabled', false); + if (!response || !response.success) { + var msg = (response && response.data && response.data.message) + ? response.data.message + : 'Could not send the code.'; + showMessage($form, msg, true); + return; + } + showMessage($form, response.data.message, false); + }).fail(function () { + showMessage($form, 'Network error. Please try again.', true); + $btn.prop('disabled', false); + }); + }); + // --- Disable ------------------------------------------------------- - $root.on('click', '.wpuf-2fa-disable-submit', function (e) { + $root.on('click', '.wpuf-2fa-method-disable', function (e) { e.preventDefault(); - var $btn = $(this); - var $form = $root.find('.wpuf-2fa-disable-form'); - var password = $form.find('.wpuf-2fa-disable-password').val(); - var code = $form.find('.wpuf-2fa-disable-code').val(); + var $btn = $(this); + var $card = $btn.closest('.wpuf-2fa-method-card'); + var methodId = methodIdFor($card); + var $form = $card.find('.wpuf-2fa-disable-form'); + var password = $form.find('.wpuf-2fa-disable-password').val(); + var code = $form.find('.wpuf-2fa-disable-code').val(); if (!password) { showMessage($form, 'Enter your current password.', true); return; } - if (!code || code.length !== 6) { - showMessage($form, 'Enter the 6-digit code from your app.', true); + if (!code) { + showMessage($form, 'Enter the verification code.', true); return; } $btn.prop('disabled', true); $.post(ajaxUrl, { - action: 'wpuf_2fa_totp_disable', + action: 'wpuf_2fa_method_disable', _wpnonce: nonce, + method_id: methodId, password: password, code: code }).done(function (response) { if (!response || !response.success) { - var msg = response && response.data && response.data.message + var msg = (response && response.data && response.data.message) ? response.data.message : 'Could not disable 2FA.'; showMessage($form, msg, true); diff --git a/assets/js/wpuf-2fa-login.js b/assets/js/wpuf-2fa-login.js new file mode 100644 index 000000000..341928020 --- /dev/null +++ b/assets/js/wpuf-2fa-login.js @@ -0,0 +1,46 @@ +/* + * WPUF — 2FA login method-picker auto-advance + * + * Loaded conditionally by Login_Controller::render_2fa_field() only on + * the picker stage. Submits the form when the user clicks / keys a + * radio so a single interaction advances to the code-entry screen. + * + * Gated by a real user gesture (pointerdown / keydown) so we don't + * submit on page load — themes / autofill / late-loaded scripts can + * otherwise fire `change` on the pre-checked radio and trigger an + * unwanted submit. The Continue button remains the no-JS fallback. + */ + +( function () { + 'use strict'; + + var radios = document.querySelectorAll( '.wpuf-2fa-method-picker__radio' ); + + if ( ! radios.length ) { + return; + } + + var userInteracted = false; + + Array.prototype.forEach.call( radios, function ( radio ) { + radio.addEventListener( 'pointerdown', function () { + userInteracted = true; + } ); + + radio.addEventListener( 'keydown', function () { + userInteracted = true; + } ); + + radio.addEventListener( 'change', function () { + if ( ! userInteracted ) { + return; + } + + var form = radio.closest( 'form' ); + + if ( form ) { + form.submit(); + } + } ); + } ); +} )(); diff --git a/includes/Assets.php b/includes/Assets.php index a5b09aeb7..0e7290bc4 100644 --- a/includes/Assets.php +++ b/includes/Assets.php @@ -194,6 +194,9 @@ public function get_styles() { 'account' => [ 'src' => WPUF_ASSET_URI . '/css/frontend/account.css', ], + '2fa-login' => [ + 'src' => WPUF_ASSET_URI . '/css/wpuf-2fa-login.css', + ], ]; return apply_filters( 'wpuf_styles_to_register', $styles ); @@ -427,6 +430,10 @@ public function get_scripts() { 'deps' => [ 'jquery' ], 'in_footer' => true, ], + '2fa-login' => [ + 'src' => WPUF_ASSET_URI . '/js/wpuf-2fa-login.js', + 'in_footer' => true, + ], ]; if ( ! empty( $api_key ) ) { diff --git a/includes/Free/Simple_Login.php b/includes/Free/Simple_Login.php index 7e2cceb28..afc9a948a 100644 --- a/includes/Free/Simple_Login.php +++ b/includes/Free/Simple_Login.php @@ -49,6 +49,18 @@ public function __construct() { add_filter( 'login_form_login', [ $this, 'default_wp_login_override' ] ); add_filter( 'authenticate', [ $this, 'successfully_authenticate' ], 30, 3 ); + + // Default Turnstile verifier — extensions can replace by hooking + // `wpuf_login_turnstile_verify` at a higher priority and returning + // their own result, or remove this filter entirely. + add_filter( 'wpuf_login_turnstile_verify', [ $this, 'default_turnstile_verify' ], 10, 2 ); + + // Default error / message renderers for the login template. Extensions + // can `remove_action()` these and replace with their own renderer + // (e.g. notice-style banners, theme-styled alerts) without having to + // override the template file. + add_action( 'wpuf_login_show_errors', [ $this, 'show_errors' ] ); + add_action( 'wpuf_login_show_messages', [ $this, 'show_messages' ] ); } /** @@ -368,6 +380,18 @@ public function login_form() { $args = [ 'action_url' => $login_page, 'redirect_to' => isset( $getdata['redirect_to'] ) ? $getdata['redirect_to'] : '', + /** + * Filter the CSS class applied to the login form wrapper. + * + * Allows Pro / extensions to apply their layout class server-side, + * eliminating the flash-of-unstyled-content caused by JS-based + * class application. + * + * @since WPUF_SINCE + * + * @param string $layout_class CSS class for the wrapper. Default empty. + */ + 'layout_class' => apply_filters( 'wpuf_login_form_layout_class', '' ), ]; switch ( $action ) { @@ -407,9 +431,9 @@ public function login_form() { default: $loggedout = isset( $getdata['loggedout'] ) ? sanitize_text_field( $getdata['loggedout'] ) : ''; - $enable_turnstile = wpuf_get_option( 'enable_turnstile', 'wpuf_general', 'off' ); + $login_form_turnstile = wpuf_get_option( 'login_form_turnstile', 'wpuf_profile', 'off' ); - if ( 'on' === $enable_turnstile ) { + if ( 'on' === $login_form_turnstile ) { wp_enqueue_script( 'wpuf-turnstile' ); } @@ -419,6 +443,34 @@ public function login_form() { $args['redirect_to'] = $this->get_login_redirect_link( $args['redirect_to'] ); + /** + * Filter the args passed to the login-form template. + * + * Lets Pro / extensions inject custom labels, placeholders, + * help text, button text, and the form heading server-side + * — so screen readers, crawlers, and page caches see the + * customized text instead of the English template defaults. + * + * Recognized keys consumed by `templates/login-form.php`: + * - `form_title` (string) heading rendered above the form + * - `form_subtitle` (string) subtitle rendered below the heading + * - `username_label` (string) label for the username/email input + * - `username_placeholder` (string) placeholder for the username input + * - `password_label` (string) label for the password input + * - `password_placeholder` (string) placeholder for the password input + * - `remember_me_text` (string) label for the remember-me checkbox + * - `lost_password_text` (string) anchor text for the lost-password link + * - `button_text` (string) value for the submit button + * + * Unknown keys are exposed to overridden templates via + * `extract()` and are otherwise ignored. + * + * @since WPUF_SINCE + * + * @param array $args Template args (passed to `wpuf_load_template`). + */ + $args = apply_filters( 'wpuf_login_form_template_args', $args ); + wpuf_load_template( 'login-form.php', $args ); break; @@ -431,16 +483,55 @@ public function login_form() { /** * Verify if cloudflare turnstile request is successful * + * Delegates to the `wpuf_login_turnstile_verify` filter so Pro / extensions + * can swap or augment the verification (e.g. alternative captcha provider, + * stubbing in tests, additional risk checks). The default callback + * `default_turnstile_verify()` retains the original Cloudflare flow. + * * @since 4.0.13 * * @return bool */ private function verify_cloudflare_turnstile_on_login() { + /** + * Filter the result of the login-form Turnstile verification. + * + * Return `true` to allow the login, `false` to block. Implementations + * that want to surface their own error message should append to + * `$this->cf_messages` (via this class) or hook `wpuf_login_errors`. + * + * @since WPUF_SINCE + * + * @param bool $is_valid Whether verification passed. Default true. + * @param Simple_Login $context This Simple_Login instance, for access to `cf_messages`. + */ + return (bool) apply_filters( 'wpuf_login_turnstile_verify', true, $this ); + } + + /** + * Default Turnstile verification — original Cloudflare HTTP flow. + * + * Registered as the default `wpuf_login_turnstile_verify` callback in + * `__construct()`. Reads `login_form_turnstile` from `wpuf_profile` to + * decide whether verification is required, then POSTs the user's token + * to Cloudflare's siteverify endpoint. + * + * Public so the filter can dispatch to it; not part of the documented + * extension surface — extensions should hook the filter, not call this + * directly. + * + * @since WPUF_SINCE + * + * @param bool $is_valid Pre-filter value (ignored; this is the default). + * @param Simple_Login $context Owning Simple_Login instance for error-message storage. + * @return bool + */ + public function default_turnstile_verify( $is_valid, $context ) { $nonce = isset( $_POST['wpuf-login-nonce'] ) ? sanitize_key( wp_unslash( $_POST['wpuf-login-nonce'] ) ) : ''; - $enable_turnstile = wpuf_get_option( 'enable_turnstile', 'wpuf_general', 'off' ); + $login_form_turnstile = wpuf_get_option( 'login_form_turnstile', 'wpuf_profile', 'off' ); - if ( 'on' !== $enable_turnstile ) { + if ( 'on' !== $login_form_turnstile ) { return true; } @@ -474,7 +565,7 @@ private function verify_cloudflare_turnstile_on_login() { if ( ! empty( $body['success'] ) ) { return true; } else { - $this->cf_messages[] = ! empty( $body['error-codes'] ) ? $body['error-codes'] : ''; + $context->cf_messages[] = ! empty( $body['error-codes'] ) ? $body['error-codes'] : ''; return false; } @@ -544,7 +635,6 @@ public function process_login() { '', $errors ); - '' . __( 'Error', 'wp-user-frontend' ) . ': ' . __( 'Cloudflare Turnstile verification failed. Reasons: [', 'wp-user-frontend' ); } $validation_error = new WP_Error(); diff --git a/includes/TwoFactor/Account_Section.php b/includes/TwoFactor/Account_Section.php index 5b6eb99e5..3351af251 100644 --- a/includes/TwoFactor/Account_Section.php +++ b/includes/TwoFactor/Account_Section.php @@ -9,21 +9,26 @@ * its content via `wpuf_account_content_security` action — same pattern * Frontend_Account uses for its built-in tabs. * + * The template iterates over `Method_Registry::active()` and renders + * each method's card through the `wpuf_2fa_security_card_html` filter. + * Methods (or Pro extensions) hook that filter to provide their own + * markup; this class doesn't know what TOTP looks like. + * * @since WPUF_SINCE */ class Account_Section { public const SECTION_ID = 'security'; - /** @var TOTP_Method */ - private $totp; + /** @var Method_Registry */ + private $registry; /** @var User_Storage */ private $storage; - public function __construct( TOTP_Method $totp, User_Storage $storage ) { - $this->totp = $totp; - $this->storage = $storage; + public function __construct( Method_Registry $registry, User_Storage $storage ) { + $this->registry = $registry; + $this->storage = $storage; add_filter( 'wpuf_account_sections', [ $this, 'register_section' ] ); add_action( 'wpuf_account_content_' . self::SECTION_ID, [ $this, 'render_section' ], 10, 2 ); @@ -48,8 +53,8 @@ public function render_section( $sections, $current_section ) { return; } - $user_id = get_current_user_id(); - $is_enrolled = $this->totp->is_enrolled( $user_id ); + $user_id = get_current_user_id(); + $methods = $this->registry->active(); wp_enqueue_script( 'wpuf-2fa-account' ); @@ -58,22 +63,22 @@ public function render_section( $sections, $current_section ) { [ 'sections' => $sections, 'current_section' => $current_section, - 'is_enrolled' => $is_enrolled, - 'enrolled_at' => $is_enrolled ? $this->storage->get_totp_enrolled_at( $user_id ) : null, - 'totp_label' => $this->totp->get_label(), + 'user_id' => $user_id, + 'methods' => $methods, + 'storage' => $this->storage, 'nonce' => wp_create_nonce( Enrollment_Controller::NONCE_ACTION ), 'ajax_url' => admin_url( 'admin-ajax.php' ), ] ); } - private function is_2fa_enabled_globally() { + private function is_2fa_enabled_globally(): bool { if ( wpuf_get_option( 'enable_2fa', 'wpuf_2fa', 'off' ) !== 'on' ) { return false; } $active = wpuf_get_option( 'active_2fa_methods', 'wpuf_2fa', [] ); - return is_array( $active ) && in_array( TOTP_Method::ID, $active, true ); + return is_array( $active ) && ! empty( $active ); } } diff --git a/includes/TwoFactor/Admin_User_Profile.php b/includes/TwoFactor/Admin_User_Profile.php index 874d7dc36..ff5a3d56f 100644 --- a/includes/TwoFactor/Admin_User_Profile.php +++ b/includes/TwoFactor/Admin_User_Profile.php @@ -6,8 +6,11 @@ * Admin reset on Users → Edit User * * Only `manage_options` (administrator) can see and trigger the reset. - * Lower-cap roles get nothing — no UI, no working POST. Audit trail - * records the actor. + * Reset loops over every method the user is currently enrolled in, + * calls `Method_Interface::reset()` on each, writes one audit entry per + * method, and fires `wpuf_2fa_method_disabled` once per method with + * `reason = 'admin_reset'`. Lower-cap roles get nothing — no UI, no + * working POST. * * @since WPUF_SINCE */ @@ -15,15 +18,15 @@ class Admin_User_Profile { public const ACTION = 'wpuf_2fa_admin_reset'; - /** @var TOTP_Method */ - private $totp; + /** @var Method_Registry */ + private $registry; /** @var User_Storage */ private $storage; - public function __construct( TOTP_Method $totp, User_Storage $storage ) { - $this->totp = $totp; - $this->storage = $storage; + public function __construct( Method_Registry $registry, User_Storage $storage ) { + $this->registry = $registry; + $this->storage = $storage; add_action( 'show_user_profile', [ $this, 'render_section' ], 35 ); add_action( 'edit_user_profile', [ $this, 'render_section' ], 35 ); @@ -35,25 +38,36 @@ public function render_section( $user ) { return; } - $is_enrolled = $this->totp->is_enrolled( $user->ID ); - $enrolled_at = $is_enrolled ? $this->storage->get_totp_enrolled_at( $user->ID ) : null; - $audit_log = $this->storage->get_audit_log( $user->ID ); + $enrolled = $this->registry->enrolled_for( $user->ID ); + $audit_log = $this->storage->get_audit_log( $user->ID ); ?>

- -

- -

+ +
    + + storage->get_method_enrolled_at( $user->ID, $method->get_id() ); + ?> +
  • + get_label() ), + esc_html( wp_date( get_option( 'date_format' ), $enrolled_at ) ) + ); + } else { + echo esc_html( $method->get_label() ); + } + ?> +
  • + +
. HTML doesn't allow @@ -67,7 +81,11 @@ public function render_section( $user ) { $reset_url = esc_url( admin_url( 'admin-post.php' ) ); $reset_nonce = esc_attr( wp_create_nonce( self::ACTION . '_' . $user->ID ) ); $reset_user_id = (int) $user->ID; - $reset_confirm = esc_attr( __( 'This will allow this user to log in without 2FA. They will need to re-enroll if they want to use 2FA again.', 'wp-user-frontend' ) ); + $reset_confirm = esc_attr( + count( $enrolled ) > 1 + ? __( 'This will disable all enrolled 2FA methods for this user. They will need to re-enroll.', 'wp-user-frontend' ) + : __( 'This will allow this user to log in without 2FA. They will need to re-enroll if they want to use 2FA again.', 'wp-user-frontend' ) + ); ?>