diff --git a/Lib/WeDevs_Settings_API.php b/Lib/WeDevs_Settings_API.php index 48cffe45b..5fd403342 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 = isset( $option['admin_label'] ) ? $option['admin_label'] : (string) $key; ?> -
+
=""> /> - -
+
<?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/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/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..9d240c09b --- /dev/null +++ b/assets/js/wpuf-2fa-account.js @@ -0,0 +1,251 @@ +/** + * WPUF Two-Factor Authentication — Account Security tab + * + * 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'; + + $(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'); + } + + 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 $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_method_start', + _wpnonce: nonce, + method_id: methodId + }).done(function (response) { + if (!response || !response.success) { + var msg = (response && response.data && response.data.message) + ? response.data.message + : 'Could not start setup.'; + showMessage($enroll, msg, true); + $btn.prop('disabled', false); + return; + } + + 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(); + } + + $enroll.removeClass('wpuf-hidden'); + $enroll.find('.wpuf-2fa-method-code').focus(); + }).fail(function () { + showMessage($enroll, 'Network error. Please try again.', true); + $btn.prop('disabled', false); + }); + }); + + // --- Enrollment: confirm ------------------------------------------ + $root.on('click', '.wpuf-2fa-method-confirm', function (e) { + e.preventDefault(); + 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_method_confirm', + _wpnonce: nonce, + method_id: methodId, + 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 state expired — bounce back to the start. + $enroll.addClass('wpuf-hidden'); + $card.find('.wpuf-2fa-method-start').prop('disabled', false); + } + return; + } + + showMessage($enroll, response.data.message, false); + window.setTimeout(function () { window.location.reload(); }, 800); + }).fail(function () { + showMessage($enroll, 'Network error. Please try again.', true); + $btn.prop('disabled', false); + }); + }); + + // --- 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-method-disable', 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'); + 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) { + showMessage($form, 'Enter the verification code.', true); + return; + } + + $btn.prop('disabled', true); + + $.post(ajaxUrl, { + 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) + ? 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/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/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 e30e84050..1a18c87a6 100644 --- a/composer.json +++ b/composer.json @@ -10,10 +10,13 @@ } ], "minimum-stability": "dev", + "prefer-stable": true, "require": { "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..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 ); @@ -422,6 +425,15 @@ 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, + ], + '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 59a18a474..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; } @@ -504,6 +595,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 ); @@ -533,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 new file mode 100644 index 000000000..3351af251 --- /dev/null +++ b/includes/TwoFactor/Account_Section.php @@ -0,0 +1,84 @@ +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 ); + } + + 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(); + $methods = $this->registry->active(); + + wp_enqueue_script( 'wpuf-2fa-account' ); + + wpuf_load_template( + 'dashboard/security.php', + [ + 'sections' => $sections, + 'current_section' => $current_section, + '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(): 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 ) && ! empty( $active ); + } +} diff --git a/includes/TwoFactor/Admin_User_Profile.php b/includes/TwoFactor/Admin_User_Profile.php new file mode 100644 index 000000000..ff5a3d56f --- /dev/null +++ b/includes/TwoFactor/Admin_User_Profile.php @@ -0,0 +1,219 @@ +registry = $registry; + $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; + } + + $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 + // 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( + 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' ) + ); + ?> + + + +

+ +
+
    + +
  • + registry->get( $method_id ) : null; + $method_lbl = $method_obj ? $method_obj->get_label() : $method_id; + $action = isset( $entry['action'] ) ? (string) $entry['action'] : ''; + $timestamp = isset( $entry['timestamp'] ) ? (int) $entry['timestamp'] : 0; + printf( + /* translators: 1: method label, 2: action label, 3: actor login, 4: timestamp */ + esc_html__( '%1$s — %2$s by %3$s on %4$s', 'wp-user-frontend' ), + esc_html( $method_lbl ), + esc_html( $this->action_label( $action ) ), + esc_html( $actor ? $actor->user_login : __( 'unknown', 'wp-user-frontend' ) ), + esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $timestamp ) ) + ); + ?> +
  • + +
+
+ registry->enrolled_for( $target_user_id ); + + foreach ( $enrolled as $method ) { + $method->reset( $target_user_id ); + + $this->storage->append_audit( + $target_user_id, + $method->get_id(), + 'admin_reset', + $actor_id + ); + + do_action( 'wpuf_2fa_method_disabled', $target_user_id, $method->get_id(), $actor_id, 'admin_reset' ); + } + + wp_safe_redirect( add_query_arg( 'wpuf_2fa_reset', '1', get_edit_user_link( $target_user_id ) ) ); + exit; + } + + private function action_label( $action ): string { + 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' ); + case 'challenge_issued': + return __( 'Challenge issued', 'wp-user-frontend' ); + case 'challenge_issue_failed': + return __( 'Challenge issue failed', 'wp-user-frontend' ); + default: + return $action; + } + } +} diff --git a/includes/TwoFactor/Crypto.php b/includes/TwoFactor/Crypto.php new file mode 100644 index 000000000..2273083dc --- /dev/null +++ b/includes/TwoFactor/Crypto.php @@ -0,0 +1,104 @@ +derive_key(), + OPENSSL_RAW_DATA, + $iv, + $tag + ); + + if ( false === $ciphertext ) { + return ''; + } + + // 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/Default_Card_Renderer.php b/includes/TwoFactor/Default_Card_Renderer.php new file mode 100644 index 000000000..abe090e06 --- /dev/null +++ b/includes/TwoFactor/Default_Card_Renderer.php @@ -0,0 +1,188 @@ +`/`