diff --git a/README.md b/README.md index fb87f9e1e..14931fbc8 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ This plugin was formerly known as WP Ultimo and is now community maintained. - **Plan Management** - Create and manage subscription plans with different features and limitations - **Template Sites** - Easily clone and use template sites for new customer websites - **Customer Dashboard** - Provide a professional management interface for your customers +- **Passwordless Login** - Let returning users sign in with passkeys or short-lived email codes - **White Labeling** - Brand the platform as your own - **Hosting Integrations** - Connect with popular hosting control panels like cPanel, RunCloud, and more diff --git a/assets/js/passwordless-auth.js b/assets/js/passwordless-auth.js new file mode 100644 index 000000000..69a50f16b --- /dev/null +++ b/assets/js/passwordless-auth.js @@ -0,0 +1,533 @@ +(function (window, document, $) { + + 'use strict'; + + const config = window.wu_passwordless_auth || {}; + const i18n = config.i18n || {}; + + function base64UrlToBuffer(input) { + + input = String(input || '').replace(/-/g, '+').replace(/_/g, '/'); + + while (input.length % 4) { + input += '='; + } + + const binary = window.atob(input); + const bytes = new Uint8Array(binary.length); + + for (let i = 0; i < binary.length; i++) { + bytes[ i ] = binary.charCodeAt(i); + } + + return bytes.buffer; + } + + function bufferToBase64Url(buffer) { + + if (! buffer) { + return ''; + } + + const bytes = new Uint8Array(buffer); + let binary = ''; + + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[ i ]); + } + + return window.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + } + + function supportsWebAuthn() { + + return !! (window.PublicKeyCredential && window.navigator.credentials && window.navigator.credentials.get && window.navigator.credentials.create); + } + + function prepareCreationOptions(options) { + + const publicKey = Object.assign({}, options.publicKey || {}); + + publicKey.challenge = base64UrlToBuffer(publicKey.challenge); + + if (publicKey.user && publicKey.user.id) { + publicKey.user = Object.assign({}, publicKey.user, { + id: base64UrlToBuffer(publicKey.user.id), + }); + } + + publicKey.excludeCredentials = (publicKey.excludeCredentials || []).map(function (credential) { + return Object.assign({}, credential, { + id: base64UrlToBuffer(credential.id), + }); + }); + + return { publicKey }; + } + + function prepareRequestOptions(options) { + + const publicKey = Object.assign({}, options.publicKey || {}); + + publicKey.challenge = base64UrlToBuffer(publicKey.challenge); + + publicKey.allowCredentials = (publicKey.allowCredentials || []).map(function (credential) { + return Object.assign({}, credential, { + id: base64UrlToBuffer(credential.id), + }); + }); + + return { publicKey }; + } + + function serializeCredential(credential) { + + const response = { + clientDataJSON: bufferToBase64Url(credential.response.clientDataJSON), + }; + + if (credential.response.attestationObject) { + response.attestationObject = bufferToBase64Url(credential.response.attestationObject); + } + + if (credential.response.authenticatorData) { + response.authenticatorData = bufferToBase64Url(credential.response.authenticatorData); + } + + if (credential.response.signature) { + response.signature = bufferToBase64Url(credential.response.signature); + } + + if (credential.response.userHandle) { + response.userHandle = bufferToBase64Url(credential.response.userHandle); + } + + if (credential.response.getTransports) { + response.transports = credential.response.getTransports(); + } + + return { + id: credential.id, + rawId: bufferToBase64Url(credential.rawId), + type: credential.type, + response, + clientExtensionResults: credential.getClientExtensionResults ? credential.getClientExtensionResults() : {}, + }; + } + + function request(action, data) { + + return new Promise(function (resolve, reject) { + + $.ajax({ + url: config.ajax_url, + method: 'POST', + data: Object.assign({}, data || {}, { + action, + nonce: config.nonce, + }), + success(response) { + + if (response && response.success) { + resolve(response.data || {}); + return; + } + + reject(response || {}); + + }, + error(error) { + reject(error); + }, + }); + + }); + } + + function getErrorMessage(error) { + + if (error && error.responseJSON && error.responseJSON.data && error.responseJSON.data.message) { + return error.responseJSON.data.message; + } + + if (error && error.data && error.data.message) { + return error.data.message; + } + + if (error && error.message) { + return error.message; + } + + return i18n.login_failed || 'Login failed. Please try again.'; + } + + function bindOnce(element, eventName, key, handler) { + + if (! element) { + return; + } + + element.wuPasswordlessBindings = element.wuPasswordlessBindings || {}; + + if (element.wuPasswordlessBindings[ key ]) { + return; + } + + element.wuPasswordlessBindings[ key ] = true; + element.addEventListener(eventName, handler); + } + + function initContainer(container) { + + if (! container) { + return; + } + + if (! container.querySelector('.wu-passwordless-start') && ! container.querySelector('.wu-passwordless-passkey') && ! container.querySelector('.wu-passwordless-verify-code') && ! container.querySelector('.wu-passwordless-create-passkey')) { + return; + } + + const state = container.wuPasswordlessState || { + identifier: '', + token: '', + authOptions: null, + registrationOptions: null, + redirectUrl: container.dataset.redirectTo || '', + }; + + container.wuPasswordlessState = state; + container.dataset.wuPasswordlessReady = '1'; + + const status = container.querySelector('.wu-passwordless-status'); + const emailInput = container.querySelector('.wu-passwordless-email'); + const codeInput = container.querySelector('.wu-passwordless-code'); + const startButton = container.querySelector('.wu-passwordless-start'); + const passkeyButton = container.querySelector('.wu-passwordless-passkey'); + const emailCodeButton = container.querySelector('.wu-passwordless-email-code'); + const verifyCodeButton = container.querySelector('.wu-passwordless-verify-code'); + const resendButton = container.querySelector('.wu-passwordless-resend'); + const createPasskeyButton = container.querySelector('.wu-passwordless-create-passkey'); + const skipPasskeyButton = container.querySelector('.wu-passwordless-skip-passkey'); + + function getIdentifier() { + + if (emailInput) { + return emailInput.value.trim(); + } + + const fieldId = container.dataset.identifierField; + + if (! fieldId) { + return ''; + } + + const field = document.getElementById(fieldId) || document.querySelector('[name="' + fieldId + '"]'); + + return field ? field.value.trim() : ''; + } + + function showStatus(message, type) { + + if (! status) { + return; + } + + status.textContent = message || ''; + status.classList.remove('wu-hidden', 'wu-bg-red-100', 'wu-text-red-800', 'wu-bg-blue-50', 'wu-text-blue-900', 'wu-bg-green-100', 'wu-text-green-800'); + + if (! message) { + status.classList.add('wu-hidden'); + return; + } + + if (type === 'error') { + status.classList.add('wu-bg-red-100', 'wu-text-red-800'); + } else if (type === 'success') { + status.classList.add('wu-bg-green-100', 'wu-text-green-800'); + } else { + status.classList.add('wu-bg-blue-50', 'wu-text-blue-900'); + } + } + + function showStep(step) { + + container.querySelectorAll('[data-wu-step]').forEach(function (element) { + const active = element.dataset.wuStep === step; + element.hidden = ! active; + element.classList.toggle('wu-hidden', ! active); + }); + + if (step === 'otp' && codeInput) { + codeInput.focus(); + } + } + + function setBusy(button, busy, label) { + + if (! button) { + return; + } + + if (busy) { + button.dataset.originalText = button.textContent; + button.disabled = true; + button.textContent = label || button.textContent; + return; + } + + button.disabled = false; + button.textContent = button.dataset.originalText || button.textContent; + } + + function finish(data) { + + if (data.nonce) { + config.nonce = data.nonce; + } + + state.redirectUrl = data.redirect_url || state.redirectUrl; + + if (data.registration_options && supportsWebAuthn()) { + state.registrationOptions = data.registration_options; + showStatus(data.message || '', 'success'); + showStep('enroll'); + return; + } + + showStatus(i18n.redirecting || 'Login successful. Redirecting...', 'success'); + + if (container.dataset.success === 'reload') { + window.location.reload(); + return; + } + + window.location.assign(data.redirect_url || state.redirectUrl || window.location.href); + } + + function start(forceOtp) { + + state.identifier = getIdentifier(); + + if (! state.identifier) { + showStatus(i18n.email_required || 'Enter your email address to continue.', 'error'); + return; + } + + setBusy(startButton, true, i18n.sending_code || 'Sending login code...'); + showStatus(forceOtp ? (i18n.sending_code || 'Sending login code...') : '', 'info'); + + request('wu_passwordless_start', { + identifier: state.identifier, + redirect_to: state.redirectUrl, + redirect_type: container.dataset.redirectType || 'default', + supports_webauthn: supportsWebAuthn() ? 1 : 0, + force_otp: forceOtp ? 1 : 0, + }).then(function (data) { + + setBusy(startButton, false); + + if (data.mode === 'passkey') { + state.authOptions = data.options; + showStep('passkey'); + showStatus(data.message || i18n.passkey_starting || '', 'info'); + usePasskey(); + return; + } + + state.token = data.token || ''; + showStep('otp'); + showStatus(data.message || '', 'info'); + + }).catch(function (error) { + + setBusy(startButton, false); + showStatus(getErrorMessage(error), 'error'); + + }); + + } + + function usePasskey() { + + if (! supportsWebAuthn()) { + showStatus(i18n.passkey_unavailable || 'Passkeys are not available in this browser.', 'error'); + start(true); + return; + } + + if (! state.authOptions) { + return; + } + + setBusy(passkeyButton, true, i18n.passkey_starting || 'Follow your browser prompt...'); + + window.navigator.credentials.get(prepareRequestOptions(state.authOptions)).then(function (credential) { + + return request('wu_passwordless_verify_passkey', { + credential: JSON.stringify(serializeCredential(credential)), + redirect_to: state.redirectUrl, + redirect_type: container.dataset.redirectType || 'default', + }); + + }).then(function (data) { + + setBusy(passkeyButton, false); + finish(data); + + }).catch(function (error) { + + setBusy(passkeyButton, false); + showStatus(getErrorMessage(error), 'error'); + showStep('passkey'); + + }); + + } + + function verifyCode() { + + const code = codeInput ? codeInput.value.trim() : ''; + + if (! code) { + showStatus(i18n.code_required || 'Enter the code from your email.', 'error'); + return; + } + + setBusy(verifyCodeButton, true, i18n.verifying_code || 'Verifying code...'); + + request('wu_passwordless_verify_otp', { + token: state.token, + code, + redirect_to: state.redirectUrl, + redirect_type: container.dataset.redirectType || 'default', + }).then(function (data) { + + setBusy(verifyCodeButton, false); + finish(data); + + }).catch(function (error) { + + setBusy(verifyCodeButton, false); + showStatus(getErrorMessage(error), 'error'); + + }); + + } + + function createPasskey() { + + if (! supportsWebAuthn()) { + finish({ redirect_url: state.redirectUrl }); + return; + } + + setBusy(createPasskeyButton, true, i18n.creating_passkey || 'Creating passkey...'); + + const optionsPromise = state.registrationOptions ? Promise.resolve({ options: state.registrationOptions }) : request('wu_passwordless_register_options', {}); + + optionsPromise.then(function (data) { + + return window.navigator.credentials.create(prepareCreationOptions(data.options || data)); + + }).then(function (credential) { + + return request('wu_passwordless_register_verify', { + credential: JSON.stringify(serializeCredential(credential)), + }); + + }).then(function () { + + setBusy(createPasskeyButton, false); + showStatus(i18n.passkey_created || 'Passkey created. Redirecting...', 'success'); + finish({ redirect_url: state.redirectUrl }); + + }).catch(function (error) { + + setBusy(createPasskeyButton, false); + showStatus(getErrorMessage(error), 'error'); + + }); + + } + + bindOnce(startButton, 'click', 'wuPasswordlessClick', function () { + start(false); + }); + + bindOnce(emailInput, 'keydown', 'wuPasswordlessKeydown', function (event) { + if (event.key === 'Enter') { + event.preventDefault(); + start(false); + } + }); + + bindOnce(passkeyButton, 'click', 'wuPasswordlessClick', usePasskey); + + bindOnce(emailCodeButton, 'click', 'wuPasswordlessClick', function () { + start(true); + }); + + bindOnce(verifyCodeButton, 'click', 'wuPasswordlessClick', verifyCode); + + bindOnce(codeInput, 'keydown', 'wuPasswordlessKeydown', function (event) { + if (event.key === 'Enter') { + event.preventDefault(); + verifyCode(); + } + }); + + bindOnce(resendButton, 'click', 'wuPasswordlessClick', function () { + start(true); + }); + + bindOnce(createPasskeyButton, 'click', 'wuPasswordlessClick', createPasskey); + + bindOnce(skipPasskeyButton, 'click', 'wuPasswordlessClick', function () { + finish({ redirect_url: state.redirectUrl }); + }); + + } + + function initAll(root) { + + if (root && root.nodeType === 1 && root.matches('.wu-passwordless-auth')) { + initContainer(root); + } + + (root || document).querySelectorAll('.wu-passwordless-auth').forEach(initContainer); + } + + window.wuPasswordlessAuth = { + init: initAll, + request, + supportsWebAuthn, + }; + + $(function () { + + initAll(document); + + if (window.MutationObserver) { + const observer = new window.MutationObserver(function (mutations) { + mutations.forEach(function (mutation) { + mutation.addedNodes.forEach(function (node) { + if (node.nodeType === 1) { + initAll(node); + + if (node.closest) { + initContainer(node.closest('.wu-passwordless-auth')); + } + } + }); + }); + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + } + + }); + +}(window, document, jQuery)); diff --git a/inc/auth/class-email-otp-service.php b/inc/auth/class-email-otp-service.php new file mode 100644 index 000000000..94acfd715 --- /dev/null +++ b/inc/auth/class-email-otp-service.php @@ -0,0 +1,359 @@ +tables->email_otp_attempt_table ?? null; + + if ($table && isset($table->table_name)) { + return $table->table_name; + } + + global $wpdb; + + return $wpdb->base_prefix . 'wu_email_otp_attempts'; + } + + /** + * Creates and sends a new OTP for a user. + * + * @since 2.13.2 + * @param \WP_User|false $user User object. + * @param string $email Email address or identifier. + * @return array|\WP_Error + */ + public function create_and_send($user, $email) { + + $email = sanitize_email($email); + + if ( ! $user instanceof \WP_User || ! is_email($email)) { + return [ + 'token' => wp_generate_password(32, false, false), + 'sent' => false, + 'generic' => true, + ]; + } + + $rate_limit = $this->check_send_rate_limit($email); + + if (is_wp_error($rate_limit)) { + return $rate_limit; + } + + $code = (string) random_int(100000, 999999); + + /** + * Filters the generated OTP code. + * + * Intended for automated tests only. + * + * @since 2.13.2 + * @param string $code Generated code. + * @param \WP_User $user User object. + */ + $code = (string) apply_filters('wu_passwordless_otp_code', $code, $user); + + $token = wp_generate_password(32, false, false); + + global $wpdb; + + $now = current_time('mysql', true); + $expires = gmdate('Y-m-d H:i:s', time() + self::TTL); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $inserted = $wpdb->insert( + $this->get_table_name(), + [ + 'email' => $email, + 'user_id' => $user->ID, + 'token_hash' => hash('sha256', $token), + 'code_hash' => wp_hash_password($code), + 'ip_hash' => $this->get_ip_hash(), + 'attempts' => 0, + 'expires_at' => $expires, + 'consumed_at' => null, + 'date_created' => $now, + ], + ['%s', '%d', '%s', '%s', '%s', '%d', '%s', '%s', '%s'] + ); + + if (false === $inserted) { + return new \WP_Error('otp_store_failed', __('Could not create a login code. Please try again.', 'ultimate-multisite')); + } + + /** + * Filters if the OTP email should be sent. + * + * @since 2.13.2 + * @param bool $send Whether to send the OTP. + * @param \WP_User $user User object. + * @param string $code Plain OTP code. + */ + $should_send = (bool) apply_filters('wu_passwordless_should_send_otp', true, $user, $code); + + if ($should_send && ! $this->send_email($user, $code)) { + $this->delete_attempt_by_token($token); + + return new \WP_Error('otp_email_failed', __('Could not send the login code email. Please try again.', 'ultimate-multisite')); + } + + return [ + 'token' => $token, + 'sent' => $should_send, + 'generic' => false, + ]; + } + + /** + * Verifies an OTP code and consumes it on success. + * + * @since 2.13.2 + * @param string $token OTP token. + * @param string $code OTP code. + * @return \WP_User|\WP_Error + */ + public function verify($token, $code) { + + global $wpdb; + + $token = sanitize_text_field($token); + $code = preg_replace('/\D+/', '', (string) $code); + $table = $this->get_table_name(); + + if (empty($token) || empty($code)) { + return new \WP_Error('invalid_otp', __('Invalid login code.', 'ultimate-multisite')); + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $attempt = $wpdb->get_row( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT * FROM {$table} WHERE token_hash = %s AND consumed_at IS NULL AND expires_at >= %s LIMIT 1", + hash('sha256', $token), + current_time('mysql', true) + ) + ); + + if ( ! $attempt) { + return new \WP_Error('invalid_otp', __('Invalid or expired login code.', 'ultimate-multisite')); + } + + if ((int) $attempt->attempts >= self::MAX_ATTEMPTS) { + return new \WP_Error('otp_attempts_exceeded', __('Too many code attempts. Request a new code and try again.', 'ultimate-multisite')); + } + + if ( ! wp_check_password($code, $attempt->code_hash)) { + $this->increment_attempts((int) $attempt->id, (int) $attempt->attempts + 1); + + return new \WP_Error('invalid_otp', __('Invalid login code.', 'ultimate-multisite')); + } + + $user = get_user_by('id', (int) $attempt->user_id); + + if ( ! $user) { + return new \WP_Error('invalid_otp_user', __('Invalid login code.', 'ultimate-multisite')); + } + + if ( ! $this->consume((int) $attempt->id)) { + return new \WP_Error('invalid_otp', __('Invalid or expired login code.', 'ultimate-multisite')); + } + + return $user; + } + + /** + * Deletes a stored OTP attempt by token. + * + * @since 2.13.2 + * @param string $token OTP token. + * @return bool + */ + protected function delete_attempt_by_token($token) { + + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $result = $wpdb->delete( + $this->get_table_name(), + [ + 'token_hash' => hash('sha256', sanitize_text_field($token)), + ], + ['%s'] + ); + + return false !== $result; + } + + /** + * Returns a stored OTP attempt row by token. + * + * @since 2.13.2 + * @param string $token OTP token. + * @return object|null + */ + public function get_attempt_by_token($token) { + + global $wpdb; + + $table = $this->get_table_name(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + return $wpdb->get_row( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT * FROM {$table} WHERE token_hash = %s LIMIT 1", + hash('sha256', sanitize_text_field($token)) + ) + ); + } + + /** + * Sends the login code email. + * + * @since 2.13.2 + * @param \WP_User $user User object. + * @param string $code OTP code. + * @return bool + */ + protected function send_email(\WP_User $user, $code) { + + $subject = __('Your Ultimate Multisite login code', 'ultimate-multisite'); + + $message = sprintf( + /* translators: 1: login code, 2: expiration minutes. */ + __('Your Ultimate Multisite login code is %1$s. It expires in %2$d minutes.', 'ultimate-multisite'), + $code, + (int) (self::TTL / MINUTE_IN_SECONDS) + ); + + return wp_mail($user->user_email, $subject, $message); + } + + /** + * Checks OTP send rate limits. + * + * @since 2.13.2 + * @param string $email Email address. + * @return true|\WP_Error + */ + protected function check_send_rate_limit($email) { + + $ip_key = 'wu_passwordless_otp_ip_' . md5($this->get_ip_hash()); + $email_key = 'wu_passwordless_otp_email_' . md5(strtolower($email)); + $ip_count = (int) get_transient($ip_key); + $em_count = (int) get_transient($email_key); + + if ($ip_count >= 10 || $em_count >= 3) { + return new \WP_Error('otp_rate_limited', __('Too many login code requests. Please try again later.', 'ultimate-multisite')); + } + + set_transient($ip_key, $ip_count + 1, 15 * MINUTE_IN_SECONDS); + set_transient($email_key, $em_count + 1, 15 * MINUTE_IN_SECONDS); + + return true; + } + + /** + * Increments OTP attempts. + * + * @since 2.13.2 + * @param int $id Attempt ID. + * @param int $attempts Attempt count. + * @return void + */ + protected function increment_attempts($id, $attempts) { + + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->update( + $this->get_table_name(), + [ + 'attempts' => absint($attempts), + ], + [ + 'id' => absint($id), + ], + ['%d'], + ['%d'] + ); + } + + /** + * Consumes an OTP attempt. + * + * @since 2.13.2 + * @param int $id Attempt ID. + * @return bool + */ + protected function consume($id) { + + global $wpdb; + + $table = $this->get_table_name(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $result = $wpdb->query( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "UPDATE {$table} SET consumed_at = %s WHERE id = %d AND consumed_at IS NULL", + current_time('mysql', true), + absint($id) + ) + ); + + return 1 === $result; + } + + /** + * Returns a salted hash for the current IP. + * + * @since 2.13.2 + * @return string + */ + protected function get_ip_hash() { + + return hash_hmac('sha256', wu_get_ip(), wp_salt('auth')); + } +} diff --git a/inc/auth/class-passkey-credential-store.php b/inc/auth/class-passkey-credential-store.php new file mode 100644 index 000000000..098e5cfdc --- /dev/null +++ b/inc/auth/class-passkey-credential-store.php @@ -0,0 +1,171 @@ +tables->passkey_credential_table ?? null; + + if ($table && isset($table->table_name)) { + return $table->table_name; + } + + global $wpdb; + + return $wpdb->base_prefix . 'wu_passkey_credentials'; + } + + /** + * Returns active credentials for a user. + * + * @since 2.13.2 + * @param int $user_id User ID. + * @return array + */ + public function get_user_credentials($user_id) { + + global $wpdb; + + $table = $this->get_table_name(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $credentials = $wpdb->get_results( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT * FROM {$table} WHERE user_id = %d AND active = 1 ORDER BY date_created ASC", + absint($user_id) + ) + ); + + return is_array($credentials) ? $credentials : []; + } + + /** + * Checks if a user has at least one passkey. + * + * @since 2.13.2 + * @param int $user_id User ID. + * @return bool + */ + public function user_has_credentials($user_id) { + + return ! empty($this->get_user_credentials($user_id)); + } + + /** + * Finds a credential by credential ID. + * + * @since 2.13.2 + * @param string $credential_id Credential ID. + * @return object|null + */ + public function find_by_credential_id($credential_id) { + + global $wpdb; + + $table = $this->get_table_name(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + return $wpdb->get_row( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT * FROM {$table} WHERE credential_id = %s AND active = 1 LIMIT 1", + sanitize_text_field($credential_id) + ) + ); + } + + /** + * Stores a credential. + * + * @since 2.13.2 + * @param int $user_id User ID. + * @param string $credential_id Credential ID. + * @param string $public_key PEM public key. + * @param int $sign_count Sign counter. + * @param string $aaguid AAGUID. + * @param array $transports Authenticator transports. + * @return bool + */ + public function create($user_id, $credential_id, $public_key, $sign_count, $aaguid = '', $transports = []) { + + global $wpdb; + + $now = current_time('mysql', true); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $result = $wpdb->replace( + $this->get_table_name(), + [ + 'user_id' => absint($user_id), + 'credential_id' => sanitize_text_field($credential_id), + 'public_key' => $public_key, + 'sign_count' => absint($sign_count), + 'aaguid' => sanitize_text_field($aaguid), + 'transports' => implode(',', array_map('sanitize_key', (array) $transports)), + 'active' => 1, + 'date_created' => $now, + 'date_updated' => $now, + 'date_last_used' => null, + ], + ['%d', '%s', '%s', '%d', '%s', '%s', '%d', '%s', '%s', '%s'] + ); + + return false !== $result; + } + + /** + * Updates a credential counter and last-used time. + * + * @since 2.13.2 + * @param int $id Credential row ID. + * @param int $sign_count New sign count. + * @return bool + */ + public function update_usage($id, $sign_count) { + + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $result = $wpdb->update( + $this->get_table_name(), + [ + 'sign_count' => absint($sign_count), + 'date_last_used' => current_time('mysql', true), + 'date_updated' => current_time('mysql', true), + ], + [ + 'id' => absint($id), + ], + ['%d', '%s', '%s'], + ['%d'] + ); + + return false !== $result; + } +} diff --git a/inc/auth/class-passkey-service.php b/inc/auth/class-passkey-service.php new file mode 100644 index 000000000..4e7c43eb8 --- /dev/null +++ b/inc/auth/class-passkey-service.php @@ -0,0 +1,297 @@ +helper = new WebAuthn_Helper(); + $this->credentials = new Passkey_Credential_Store(); + $this->challenges = new WebAuthn_Challenge_Store(); + } + + /** + * Returns the credential store. + * + * @since 2.13.2 + * @return Passkey_Credential_Store + */ + public function credentials() { + + return $this->credentials; + } + + /** + * Generates passkey registration options for a user. + * + * @since 2.13.2 + * @param \WP_User $user User object. + * @return array + */ + public function get_registration_options(\WP_User $user) { + + $rp_id = $this->helper->get_rp_id(); + $origin = $this->helper->get_origin(); + $challenge = $this->challenges->create('registration', $user->ID, $rp_id, $origin); + $existing = []; + + foreach ($this->credentials->get_user_credentials($user->ID) as $credential) { + $existing[] = [ + 'type' => 'public-key', + 'id' => $credential->credential_id, + ]; + } + + return [ + 'publicKey' => [ + 'challenge' => $challenge, + 'rp' => [ + 'name' => get_bloginfo('name') ?: 'Ultimate Multisite', + 'id' => $rp_id, + ], + 'user' => [ + 'id' => $this->helper->base64url_encode((string) $user->ID), + 'name' => $user->user_email ?: $user->user_login, + 'displayName' => $user->display_name ?: $user->user_login, + ], + 'pubKeyCredParams' => [ + [ + 'type' => 'public-key', + 'alg' => -7, + ], + ], + 'timeout' => 60000, + 'attestation' => 'none', + 'excludeCredentials' => $existing, + 'authenticatorSelection' => [ + 'residentKey' => 'preferred', + 'userVerification' => 'preferred', + ], + ], + ]; + } + + /** + * Generates passkey authentication options for a user. + * + * @since 2.13.2 + * @param \WP_User $user User object. + * @return array|\WP_Error + */ + public function get_authentication_options(\WP_User $user) { + + $credentials = $this->credentials->get_user_credentials($user->ID); + + if (empty($credentials)) { + return new \WP_Error('no_passkeys', __('No passkeys are registered for this account.', 'ultimate-multisite')); + } + + $rp_id = $this->helper->get_rp_id(); + $origin = $this->helper->get_origin(); + $challenge = $this->challenges->create('authentication', $user->ID, $rp_id, $origin); + $allow = []; + + foreach ($credentials as $credential) { + $allow[] = [ + 'type' => 'public-key', + 'id' => $credential->credential_id, + ]; + } + + return [ + 'publicKey' => [ + 'challenge' => $challenge, + 'timeout' => 60000, + 'rpId' => $rp_id, + 'allowCredentials' => $allow, + 'userVerification' => 'preferred', + ], + ]; + } + + /** + * Verifies and stores a registration response. + * + * @since 2.13.2 + * @param int $user_id User ID. + * @param array $payload Browser credential payload. + * @return true|\WP_Error + */ + public function verify_registration($user_id, $payload) { + + $user = get_user_by('id', absint($user_id)); + + if ( ! $user) { + return new \WP_Error('invalid_user', __('Invalid passkey user.', 'ultimate-multisite')); + } + + $client_data = $this->helper->parse_client_data($payload['response']['clientDataJSON'] ?? '', 'webauthn.create'); + + if (is_wp_error($client_data)) { + return $client_data; + } + + $challenge = $this->challenges->get_valid($client_data['challenge'], 'registration'); + + if ( ! $challenge || (int) $challenge->user_id !== (int) $user->ID) { + return new \WP_Error('invalid_challenge', __('Invalid or expired passkey challenge.', 'ultimate-multisite')); + } + + if ( ! $this->helper->is_origin_allowed($client_data['origin'], $challenge->origin, $challenge->rp_id)) { + return new \WP_Error('invalid_origin', __('Invalid passkey origin.', 'ultimate-multisite')); + } + + $attestation = $this->helper->parse_attestation_object($payload['response']['attestationObject'] ?? ''); + + if (is_wp_error($attestation)) { + return $attestation; + } + + if (hash('sha256', $challenge->rp_id, true) !== $attestation['rp_id_hash']) { + return new \WP_Error('invalid_rp_id', __('Invalid passkey relying party.', 'ultimate-multisite')); + } + + if ( ! $this->challenges->mark_used((int) $challenge->id)) { + return new \WP_Error('invalid_challenge', __('Invalid or expired passkey challenge.', 'ultimate-multisite')); + } + + $transports = $payload['response']['transports'] ?? []; + + $created = $this->credentials->create( + $user->ID, + $attestation['credential_id'], + $attestation['public_key'], + $attestation['sign_count'], + $attestation['aaguid'], + is_array($transports) ? $transports : [] + ); + + if ( ! $created) { + return new \WP_Error('credential_store_failed', __('Could not store the passkey. Please try again.', 'ultimate-multisite')); + } + + return true; + } + + /** + * Verifies an authentication response. + * + * @since 2.13.2 + * @param array $payload Browser credential payload. + * @return \WP_User|\WP_Error + */ + public function verify_authentication($payload) { + + $credential_id = sanitize_text_field($payload['rawId'] ?? $payload['id'] ?? ''); + $credential = $this->credentials->find_by_credential_id($credential_id); + + if ( ! $credential) { + return new \WP_Error('unknown_credential', __('This passkey is not registered.', 'ultimate-multisite')); + } + + $client_data = $this->helper->parse_client_data($payload['response']['clientDataJSON'] ?? '', 'webauthn.get'); + + if (is_wp_error($client_data)) { + return $client_data; + } + + $challenge = $this->challenges->get_valid($client_data['challenge'], 'authentication'); + + if ( ! $challenge || (int) $challenge->user_id !== (int) $credential->user_id) { + return new \WP_Error('invalid_challenge', __('Invalid or expired passkey challenge.', 'ultimate-multisite')); + } + + if ( ! $this->helper->is_origin_allowed($client_data['origin'], $challenge->origin, $challenge->rp_id)) { + return new \WP_Error('invalid_origin', __('Invalid passkey origin.', 'ultimate-multisite')); + } + + $authenticator_data = $this->helper->parse_assertion_authenticator_data($payload['response']['authenticatorData'] ?? ''); + + if (is_wp_error($authenticator_data)) { + return $authenticator_data; + } + + if (hash('sha256', $challenge->rp_id, true) !== $authenticator_data['rp_id_hash']) { + return new \WP_Error('invalid_rp_id', __('Invalid passkey relying party.', 'ultimate-multisite')); + } + + $verified = $this->helper->verify_assertion_signature( + $payload['response']['authenticatorData'] ?? '', + $client_data['_raw'], + $payload['response']['signature'] ?? '', + $credential->public_key + ); + + if ( ! $verified) { + return new \WP_Error('invalid_signature', __('Passkey verification failed.', 'ultimate-multisite')); + } + + $old_count = (int) $credential->sign_count; + $new_count = (int) $authenticator_data['sign_count']; + + if ($old_count > 0 && $new_count > 0 && $new_count <= $old_count) { + return new \WP_Error('invalid_sign_count', __('Passkey counter verification failed.', 'ultimate-multisite')); + } + + if ( ! $this->challenges->mark_used((int) $challenge->id)) { + return new \WP_Error('invalid_challenge', __('Invalid or expired passkey challenge.', 'ultimate-multisite')); + } + + $user = get_user_by('id', (int) $credential->user_id); + + if ( ! $user) { + return new \WP_Error('invalid_user', __('Invalid passkey user.', 'ultimate-multisite')); + } + + if ( ! $this->credentials->update_usage((int) $credential->id, $new_count)) { + return new \WP_Error('credential_update_failed', __('Could not finalize passkey authentication. Please try again.', 'ultimate-multisite')); + } + + return $user; + } +} diff --git a/inc/auth/class-passwordless-auth-manager.php b/inc/auth/class-passwordless-auth-manager.php new file mode 100644 index 000000000..de63a17ea --- /dev/null +++ b/inc/auth/class-passwordless-auth-manager.php @@ -0,0 +1,684 @@ +passkeys = new Passkey_Service(); + $this->otp = new Email_OTP_Service(); + + add_action('init', [$this, 'register_assets']); + add_action('init', [$this, 'maybe_install_tables'], 5); + + add_action('login_enqueue_scripts', [$this, 'enqueue_login_assets']); + add_action('login_form', [$this, 'render_wp_login_form']); + + $this->register_ajax_hooks(); + } + + /** + * Returns the passkey service. + * + * @since 2.13.2 + * @return Passkey_Service + */ + public function passkeys() { + + return $this->passkeys; + } + + /** + * Returns the OTP service. + * + * @since 2.13.2 + * @return Email_OTP_Service + */ + public function otp() { + + return $this->otp; + } + + /** + * Registers AJAX hooks on both WordPress AJAX and Ultimate Multisite light AJAX. + * + * @since 2.13.2 + * @return void + */ + protected function register_ajax_hooks() { + + $public_actions = [ + 'wu_passwordless_start' => 'ajax_start', + 'wu_passwordless_verify_otp' => 'ajax_verify_otp', + 'wu_passwordless_verify_passkey' => 'ajax_verify_passkey', + ]; + + $private_actions = [ + 'wu_passwordless_register_options' => 'ajax_register_options', + 'wu_passwordless_register_verify' => 'ajax_register_verify', + ]; + + foreach ($public_actions as $action => $method) { + add_action('wp_ajax_' . $action, [$this, $method]); + add_action('wp_ajax_nopriv_' . $action, [$this, $method]); + add_action('wu_ajax_' . $action, [$this, $method]); + add_action('wu_ajax_nopriv_' . $action, [$this, $method]); + } + + foreach ($private_actions as $action => $method) { + add_action('wp_ajax_' . $action, [$this, $method]); + add_action('wu_ajax_' . $action, [$this, $method]); + } + } + + /** + * Installs auth tables when an upgraded site first needs them. + * + * @since 2.13.2 + * @return void + */ + public function maybe_install_tables() { + + $table_names = [ + 'passkey_credential_table', + 'webauthn_challenge_table', + 'email_otp_attempt_table', + ]; + + foreach ($table_names as $table_name) { + $table = WP_Ultimo()->tables->{$table_name} ?? null; + + if ($table && method_exists($table, 'install') && ! $this->table_exists($table->table_name)) { + $table->install(); + } + } + } + + /** + * Checks if a database table already exists. + * + * BerlinDB caches table existence early in the request, so upgraded/test + * environments need a direct SHOW TABLES check before calling install(). + * + * @since 2.13.2 + * @param string $table_name Fully qualified table name. + * @return bool + */ + protected function table_exists($table_name) { + + global $wpdb; + + $table_name = sanitize_key($table_name); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $found = $wpdb->get_var( + $wpdb->prepare('SHOW TABLES LIKE %s', $table_name) + ); + + return $found === $table_name; + } + + /** + * Registers passwordless assets. + * + * @since 2.13.2 + * @return void + */ + public function register_assets() { + + $script_url = wu_url('assets/js/passwordless-auth.js'); + + if (file_exists(wu_path('assets/js/passwordless-auth.min.js'))) { + $script_url = wu_get_asset('passwordless-auth.js', 'js'); + } + + wp_register_script( + 'wu-passwordless-auth', + $script_url, + ['jquery-core'], + wu_get_version(), + true + ); + } + + /** + * Enqueues passwordless assets. + * + * @since 2.13.2 + * @return void + */ + public function enqueue_assets() { + + if ( ! wp_script_is('wu-passwordless-auth', 'registered')) { + $this->register_assets(); + } + + wp_localize_script( + 'wu-passwordless-auth', + 'wu_passwordless_auth', + [ + 'ajax_url' => admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce('wu_passwordless_auth'), + 'i18n' => [ + 'email_required' => __('Enter your email address to continue.', 'ultimate-multisite'), + 'code_required' => __('Enter the code from your email.', 'ultimate-multisite'), + 'passkey_starting' => __('Follow your browser prompt to continue with your passkey.', 'ultimate-multisite'), + 'passkey_unavailable' => __('Passkeys are not available in this browser. We can email you a login code instead.', 'ultimate-multisite'), + 'sending_code' => __('Sending login code...', 'ultimate-multisite'), + 'verifying_code' => __('Verifying code...', 'ultimate-multisite'), + 'creating_passkey' => __('Creating passkey...', 'ultimate-multisite'), + 'login_failed' => __('Login failed. Please try again.', 'ultimate-multisite'), + 'passkey_created' => __('Passkey created. Redirecting...', 'ultimate-multisite'), + 'redirecting' => __('Login successful. Redirecting...', 'ultimate-multisite'), + ], + ] + ); + + wp_enqueue_script('wu-passwordless-auth'); + } + + /** + * Enqueues assets on wp-login.php and hides the legacy password form. + * + * @since 2.13.2 + * @return void + */ + public function enqueue_login_assets() { + + $this->enqueue_assets(); + + if ($this->is_password_fallback()) { + return; + } + + wp_add_inline_style( + 'login', + 'body.login #loginform > p, body.login #loginform .user-pass-wrap, body.login #loginform .forgetmenot, body.login #loginform .submit { display: none; } body.login #loginform .wu-passwordless-auth { margin: 0 0 16px; } body.login #loginform .wu-passwordless-auth p { margin: 0 0 12px; }' + ); + } + + /** + * Renders the passwordless UI on wp-login.php. + * + * @since 2.13.2 + * @return void + */ + public function render_wp_login_form() { + + if ($this->is_password_fallback()) { + return; + } + + $redirect_to = isset($_REQUEST['redirect_to']) ? sanitize_url(wp_unslash($_REQUEST['redirect_to'])) : admin_url(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $fallback_url = add_query_arg('wu_password_fallback', '1', wp_login_url($redirect_to)); + + $markup = $this->get_login_form_markup( + [ + 'context' => 'wp-login', + 'redirect_to' => $redirect_to, + 'redirect_type' => 'query_redirect', + 'fallback_url' => $fallback_url, + ] + ); + + echo $markup; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + + /** + * Returns login form markup for custom login surfaces. + * + * @since 2.13.2 + * @param array $args Markup arguments. + * @return string + */ + public function get_login_form_markup($args = []) { + + $args = wp_parse_args( + $args, + [ + 'context' => 'login-form', + 'redirect_to' => admin_url(), + 'redirect_type' => 'default', + 'success' => 'redirect', + 'show_email' => true, + 'identifier_field' => '', + 'fallback_url' => '', + ] + ); + + ob_start(); + + ?> +
+ get_login_form_markup( + [ + 'context' => 'checkout-' . sanitize_key($field_type), + 'redirect_to' => wu_get_current_url(), + 'redirect_type' => 'query_redirect', + 'success' => 'reload', + 'show_email' => false, + 'identifier_field' => 'email' === $field_type ? 'email_address' : 'username', + ] + ); + } + + /** + * Starts passwordless login. + * + * @since 2.13.2 + * @return void + */ + public function ajax_start() { + + $this->verify_ajax_request(); + + $identifier = sanitize_text_field(wu_request('identifier')); + $user = $this->find_user($identifier); + + $email = $user ? $user->user_email : $identifier; + $otp = $this->otp->create_and_send($user, $email); + + if (is_wp_error($otp)) { + $this->send_error($otp); + } + + wp_send_json_success( + [ + 'mode' => 'otp', + 'token' => $otp['token'], + 'message' => __('If an account exists for that address, we sent a short-lived login code.', 'ultimate-multisite'), + ] + ); + } + + /** + * Verifies OTP login. + * + * @since 2.13.2 + * @return void + */ + public function ajax_verify_otp() { + + $this->verify_ajax_request(); + + $user = $this->otp->verify(wu_request('token'), wu_request('code')); + + if (is_wp_error($user)) { + $this->send_error($user); + } + + $redirect_url = $this->login_user($user); + $registration = null; + + update_user_meta($user->ID, '_wu_passwordless_last_otp_login', time()); + + if ( ! $this->passkeys->credentials()->user_has_credentials($user->ID)) { + $registration = $this->passkeys->get_registration_options($user); + } + + wp_send_json_success( + [ + 'message' => __('Login successful.', 'ultimate-multisite'), + 'nonce' => wp_create_nonce('wu_passwordless_auth'), + 'redirect_url' => $redirect_url, + 'registration_options' => $registration, + ] + ); + } + + /** + * Verifies passkey login. + * + * @since 2.13.2 + * @return void + */ + public function ajax_verify_passkey() { + + $this->verify_ajax_request(); + + $credential = json_decode($this->get_json_request_value('credential'), true); + + if ( ! is_array($credential)) { + $this->send_error(new \WP_Error('invalid_credential', __('Invalid passkey response.', 'ultimate-multisite'))); + } + + $user = $this->passkeys->verify_authentication($credential); + + if (is_wp_error($user)) { + $this->send_error($user); + } + + wp_send_json_success( + [ + 'message' => __('Login successful.', 'ultimate-multisite'), + 'redirect_url' => $this->login_user($user), + ] + ); + } + + /** + * Returns passkey registration options for the logged-in user. + * + * @since 2.13.2 + * @return void + */ + public function ajax_register_options() { + + $this->verify_ajax_request(); + + if ( ! is_user_logged_in()) { + $this->send_error(new \WP_Error('not_logged_in', __('You need to be logged in to create a passkey.', 'ultimate-multisite'))); + } + + wp_send_json_success( + [ + 'options' => $this->passkeys->get_registration_options(wp_get_current_user()), + ] + ); + } + + /** + * Verifies and stores a passkey registration. + * + * @since 2.13.2 + * @return void + */ + public function ajax_register_verify() { + + $this->verify_ajax_request(); + + if ( ! is_user_logged_in()) { + $this->send_error(new \WP_Error('not_logged_in', __('You need to be logged in to create a passkey.', 'ultimate-multisite'))); + } + + $credential = json_decode($this->get_json_request_value('credential'), true); + + if ( ! is_array($credential)) { + $this->send_error(new \WP_Error('invalid_credential', __('Invalid passkey response.', 'ultimate-multisite'))); + } + + $result = $this->passkeys->verify_registration(get_current_user_id(), $credential); + + if (is_wp_error($result)) { + $this->send_error($result); + } + + wp_send_json_success( + [ + 'message' => __('Passkey created.', 'ultimate-multisite'), + ] + ); + } + + /** + * Verifies the passwordless AJAX nonce. + * + * @since 2.13.2 + * @return void + */ + protected function verify_ajax_request() { + + check_ajax_referer('wu_passwordless_auth', 'nonce'); + } + + /** + * Finds a user by email or username. + * + * @since 2.13.2 + * @param string $identifier Email address or username. + * @return \WP_User|false + */ + protected function find_user($identifier) { + + $identifier = trim((string) $identifier); + + if (empty($identifier)) { + return false; + } + + if (is_email($identifier)) { + return get_user_by('email', sanitize_email($identifier)); + } + + return get_user_by('login', sanitize_user($identifier)); + } + + /** + * Returns an unslashed JSON request value. + * + * Passkey browser responses are JSON payloads that contain base64url-encoded + * binary values. Sanitizing the raw JSON string before decoding can corrupt + * the credential response, so individual decoded fields are validated by the + * passkey service instead. + * + * @since 2.13.2 + * @param string $key Request key. + * @return string + */ + protected function get_json_request_value($key) { + + if ( ! isset($_POST[ $key ])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing + return ''; + } + + return wp_unslash($_POST[ $key ]); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + } + + /** + * Logs in a user and returns the redirect URL. + * + * @since 2.13.2 + * @param \WP_User $user User object. + * @return string + */ + protected function login_user(\WP_User $user) { + + $remember = (bool) wu_request('remember'); + $requested = sanitize_url(wu_request('redirect_to', admin_url())); + $redirect_type = sanitize_key(wu_request('redirect_type', 'default')); + $redirect_to = $this->resolve_redirect($user, $requested, $redirect_type); + + wp_clear_auth_cookie(); + wp_set_current_user($user->ID); + wp_set_auth_cookie($user->ID, $remember, is_ssl()); + do_action('wp_login', $user->user_login, $user); + + $previous_redirect_type = isset($_REQUEST['wu_login_form_redirect_type']) ? sanitize_key(wp_unslash($_REQUEST['wu_login_form_redirect_type'])) : null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $_REQUEST['wu_login_form_redirect_type'] = 'query_redirect'; + + $redirect_to = apply_filters('login_redirect', $redirect_to, $redirect_to, $user); + + if (null === $previous_redirect_type) { + unset($_REQUEST['wu_login_form_redirect_type']); + } else { + $_REQUEST['wu_login_form_redirect_type'] = $previous_redirect_type; + } + + return $redirect_to; + } + + /** + * Resolves custom login form redirect targets. + * + * @since 2.13.2 + * @param \WP_User $user User object. + * @param string $requested Requested redirect. + * @param string $redirect_type Redirect type. + * @return string + */ + protected function resolve_redirect(\WP_User $user, $requested, $redirect_type) { + + if ('customer_site' === $redirect_type) { + $site = get_active_blog_for_user($user->ID); + + if ($site && ! empty($site->siteurl)) { + return trailingslashit($site->siteurl) . ltrim($requested ?: 'wp-admin', '/'); + } + } + + if ('main_site' === $redirect_type) { + return home_url($requested ?: '/wp-admin'); + } + + return wp_validate_redirect($requested, admin_url()); + } + + /** + * Sends a normalized JSON error response. + * + * @since 2.13.2 + * @param \WP_Error $error Error object. + * @return void + */ + protected function send_error(\WP_Error $error) { + + wp_send_json_error( + [ + 'code' => $error->get_error_code(), + 'message' => wp_strip_all_tags($error->get_error_message()), + ] + ); + } + + /** + * Checks if default password fallback is explicitly requested. + * + * @since 2.13.2 + * @return bool + */ + protected function is_password_fallback() { + + return isset($_GET['wu_password_fallback']); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + } +} diff --git a/inc/auth/class-webauthn-challenge-store.php b/inc/auth/class-webauthn-challenge-store.php new file mode 100644 index 000000000..1a636515d --- /dev/null +++ b/inc/auth/class-webauthn-challenge-store.php @@ -0,0 +1,140 @@ +tables->webauthn_challenge_table ?? null; + + if ($table && isset($table->table_name)) { + return $table->table_name; + } + + global $wpdb; + + return $wpdb->base_prefix . 'wu_webauthn_challenges'; + } + + /** + * Creates a challenge record. + * + * @since 2.13.2 + * @param string $type Challenge type. + * @param int $user_id User ID. + * @param string $rp_id Relying party ID. + * @param string $origin Request origin. + * @return string Challenge value. + */ + public function create($type, $user_id, $rp_id, $origin) { + + global $wpdb; + + $helper = new WebAuthn_Helper(); + $challenge = $helper->generate_challenge(); + $now = current_time('mysql', true); + $expires = gmdate('Y-m-d H:i:s', time() + self::TTL); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->insert( + $this->get_table_name(), + [ + 'challenge_hash' => hash('sha256', $challenge), + 'type' => sanitize_key($type), + 'user_id' => absint($user_id), + 'rp_id' => sanitize_text_field($rp_id), + 'origin' => esc_url_raw($origin), + 'expires_at' => $expires, + 'used_at' => null, + 'date_created' => $now, + ], + ['%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s'] + ); + + return $challenge; + } + + /** + * Retrieves a valid challenge by challenge value and type. + * + * @since 2.13.2 + * @param string $challenge Challenge value. + * @param string $type Challenge type. + * @return object|null + */ + public function get_valid($challenge, $type) { + + global $wpdb; + + $table = $this->get_table_name(); + $hash = hash('sha256', (string) $challenge); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + return $wpdb->get_row( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT * FROM {$table} WHERE challenge_hash = %s AND type = %s AND used_at IS NULL AND expires_at >= %s LIMIT 1", + $hash, + sanitize_key($type), + current_time('mysql', true) + ) + ); + } + + /** + * Marks a challenge as used. + * + * @since 2.13.2 + * @param int $id Challenge ID. + * @return bool + */ + public function mark_used($id) { + + global $wpdb; + + $table = $this->get_table_name(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $result = $wpdb->query( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "UPDATE {$table} SET used_at = %s WHERE id = %d AND used_at IS NULL", + current_time('mysql', true), + absint($id) + ) + ); + + return 1 === $result; + } +} diff --git a/inc/auth/class-webauthn-helper.php b/inc/auth/class-webauthn-helper.php new file mode 100644 index 000000000..82beb396b --- /dev/null +++ b/inc/auth/class-webauthn-helper.php @@ -0,0 +1,526 @@ +base64url_encode(random_bytes(32)); + } + + /** + * Returns the relying party ID for the current request. + * + * @since 2.13.2 + * @return string + */ + public function get_rp_id() { + + $host = isset($_SERVER['HTTP_HOST']) ? strtolower(sanitize_text_field(wp_unslash($_SERVER['HTTP_HOST']))) : ''; + + if (empty($host)) { + $host = (string) wp_parse_url(home_url(), PHP_URL_HOST); + } + + $host = (string) preg_replace('/^(\[[0-9a-f:]+\]):\d+$|^([^\[\]]+):\d+$/i', '$1$2', $host); + + return trim($host, '[]'); + } + + /** + * Returns the current origin, including a port when present. + * + * @since 2.13.2 + * @return string + */ + public function get_origin() { + + $host = isset($_SERVER['HTTP_HOST']) ? strtolower(sanitize_text_field(wp_unslash($_SERVER['HTTP_HOST']))) : ''; + + if (empty($host)) { + $host = (string) wp_parse_url(home_url(), PHP_URL_HOST); + } + + return (is_ssl() ? 'https://' : 'http://') . $host; + } + + /** + * Validates an origin against the challenge origin and RP ID. + * + * @since 2.13.2 + * @param string $origin Client supplied origin. + * @param string $expected_origin Origin stored with the challenge. + * @param string $rp_id Expected RP ID. + * @return bool + */ + public function is_origin_allowed($origin, $expected_origin, $rp_id) { + + $origin = $this->normalize_origin($origin); + $expected_origin = $this->normalize_origin($expected_origin); + + if (empty($origin) || empty($expected_origin) || $origin !== $expected_origin) { + return false; + } + + $scheme = (string) wp_parse_url($origin, PHP_URL_SCHEME); + $host = (string) wp_parse_url($origin, PHP_URL_HOST); + + if ($host !== $rp_id) { + return false; + } + + if ('https' === $scheme) { + return true; + } + + return $this->is_local_development_host($host); + } + + /** + * Normalizes an origin for comparison. + * + * @since 2.13.2 + * @param string $origin Origin URL. + * @return string + */ + public function normalize_origin($origin) { + + $scheme = (string) wp_parse_url($origin, PHP_URL_SCHEME); + $host = (string) wp_parse_url($origin, PHP_URL_HOST); + $port = wp_parse_url($origin, PHP_URL_PORT); + + if (empty($scheme) || empty($host)) { + return ''; + } + + $normalized = strtolower($scheme) . '://' . strtolower($host); + + if ($port) { + $normalized .= ':' . absint($port); + } + + return $normalized; + } + + /** + * Parses clientDataJSON. + * + * @since 2.13.2 + * @param string $client_data_json Encoded clientDataJSON. + * @param string $expected_type Expected WebAuthn type. + * @return array|\WP_Error + */ + public function parse_client_data($client_data_json, $expected_type) { + + $decoded = $this->base64url_decode($client_data_json); + + if (false === $decoded) { + return new \WP_Error('invalid_client_data', __('Invalid WebAuthn client data.', 'ultimate-multisite')); + } + + $data = json_decode($decoded, true); + + if ( ! is_array($data) || empty($data['type']) || empty($data['challenge']) || empty($data['origin'])) { + return new \WP_Error('invalid_client_data', __('Invalid WebAuthn client data.', 'ultimate-multisite')); + } + + if ($expected_type !== $data['type']) { + return new \WP_Error('invalid_client_type', __('Unexpected WebAuthn response type.', 'ultimate-multisite')); + } + + $data['_raw'] = $decoded; + + return $data; + } + + /** + * Parses an attestation object and extracts credential data. + * + * @since 2.13.2 + * @param string $attestation_object Encoded attestation object. + * @return array|\WP_Error + */ + public function parse_attestation_object($attestation_object) { + + $decoded = $this->base64url_decode($attestation_object); + + if (false === $decoded) { + return new \WP_Error('invalid_attestation', __('Invalid passkey registration response.', 'ultimate-multisite')); + } + + try { + $offset = 0; + $object = $this->decode_cbor_item($decoded, $offset); + } catch (\Throwable $exception) { + return new \WP_Error('invalid_attestation', __('Invalid passkey registration response.', 'ultimate-multisite')); + } + + if ( ! is_array($object) || empty($object['authData'])) { + return new \WP_Error('invalid_attestation', __('Invalid passkey registration response.', 'ultimate-multisite')); + } + + return $this->parse_authenticator_data($object['authData'], true); + } + + /** + * Parses assertion authenticator data. + * + * @since 2.13.2 + * @param string $authenticator_data Encoded authenticator data. + * @return array|\WP_Error + */ + public function parse_assertion_authenticator_data($authenticator_data) { + + $decoded = $this->base64url_decode($authenticator_data); + + if (false === $decoded) { + return new \WP_Error('invalid_authenticator_data', __('Invalid passkey authentication response.', 'ultimate-multisite')); + } + + return $this->parse_authenticator_data($decoded, false); + } + + /** + * Verifies an assertion signature. + * + * @since 2.13.2 + * @param string $authenticator_data Encoded authenticator data. + * @param string $client_data_raw Raw clientDataJSON. + * @param string $signature Encoded signature. + * @param string $public_key PEM public key. + * @return bool + */ + public function verify_assertion_signature($authenticator_data, $client_data_raw, $signature, $public_key) { + + $raw_authenticator_data = $this->base64url_decode($authenticator_data); + $raw_signature = $this->base64url_decode($signature); + + if (false === $raw_authenticator_data || false === $raw_signature) { + return false; + } + + $signed_data = $raw_authenticator_data . hash('sha256', $client_data_raw, true); + $verified = openssl_verify($signed_data, $raw_signature, $public_key, OPENSSL_ALGO_SHA256); + + return 1 === $verified; + } + + /** + * Parses authenticator data. + * + * @since 2.13.2 + * @param string $auth_data Raw authenticator data. + * @param bool $expect_attested_payload True when registration data is expected. + * @return array|\WP_Error + */ + public function parse_authenticator_data($auth_data, $expect_attested_payload) { + + if (strlen($auth_data) < 37) { + return new \WP_Error('invalid_authenticator_data', __('Invalid passkey response.', 'ultimate-multisite')); + } + + $rp_id_hash = substr($auth_data, 0, 32); + $flags = ord($auth_data[32]); + $counter = unpack('N', substr($auth_data, 33, 4)); + + if ( ! ($flags & 0x01)) { + return new \WP_Error('user_presence_required', __('Passkey verification requires user presence.', 'ultimate-multisite')); + } + + $result = [ + 'rp_id_hash' => $rp_id_hash, + 'flags' => $flags, + 'sign_count' => absint($counter[1] ?? 0), + ]; + + if ( ! $expect_attested_payload) { + return $result; + } + + if ( ! ($flags & 0x40)) { + return new \WP_Error('missing_attested_data', __('Passkey registration did not include credential data.', 'ultimate-multisite')); + } + + if (strlen($auth_data) < 55) { + return new \WP_Error('invalid_attested_data', __('Invalid passkey credential data.', 'ultimate-multisite')); + } + + $offset = 37; + $aaguid = substr($auth_data, $offset, 16); + $offset += 16; + $credential_id_length = unpack('n', substr($auth_data, $offset, 2)); + $credential_id_length = absint($credential_id_length[1] ?? 0); + $offset += 2; + + if ($credential_id_length < 16 || strlen($auth_data) < ($offset + $credential_id_length + 1)) { + return new \WP_Error('invalid_credential_id', __('Invalid passkey credential ID.', 'ultimate-multisite')); + } + + $credential_id = substr($auth_data, $offset, $credential_id_length); + $offset += $credential_id_length; + + try { + $public_key_cose = $this->decode_cbor_item($auth_data, $offset); + $public_key_pem = $this->cose_key_to_pem($public_key_cose); + } catch (\Throwable $exception) { + return new \WP_Error('invalid_public_key', __('Unsupported passkey public key.', 'ultimate-multisite')); + } + + if (is_wp_error($public_key_pem)) { + return $public_key_pem; + } + + $result['aaguid'] = bin2hex($aaguid); + $result['credential_id'] = $this->base64url_encode($credential_id); + $result['public_key'] = $public_key_pem; + + return $result; + } + + /** + * Checks if a host is acceptable for non-HTTPS local development. + * + * @since 2.13.2 + * @param string $host Host name. + * @return bool + */ + protected function is_local_development_host($host) { + + $host = strtolower($host); + + return in_array($host, ['localhost', '127.0.0.1', '::1'], true) || (bool) preg_match('/\.(local|test|localhost)$/', $host); + } + + /** + * Decodes one CBOR item. + * + * @since 2.13.2 + * @param string $data Binary CBOR data. + * @param int $offset Current offset. + * @return mixed + * @throws \UnexpectedValueException When CBOR data is invalid or unsupported. + */ + private function decode_cbor_item($data, &$offset) { + + if ($offset >= strlen($data)) { + throw new \UnexpectedValueException('Unexpected end of CBOR data.'); + } + + $initial = ord($data[ $offset++ ]); + $major = $initial >> 5; + $extra = $initial & 0x1f; + $length = $this->decode_cbor_length($data, $offset, $extra); + + switch ($major) { + case 0: + return $length; + + case 1: + return -1 - $length; + + case 2: + return $this->read_cbor_bytes($data, $offset, $length); + + case 3: + return $this->read_cbor_bytes($data, $offset, $length); + + case 4: + $array = []; + + for ($i = 0; $i < $length; $i++) { + $array[] = $this->decode_cbor_item($data, $offset); + } + + return $array; + + case 5: + $map = []; + + for ($i = 0; $i < $length; $i++) { + $key = $this->decode_cbor_item($data, $offset); + $map[ $key ] = $this->decode_cbor_item($data, $offset); + } + + return $map; + + case 7: + if (20 === $extra) { + return false; + } + + if (21 === $extra) { + return true; + } + + if (22 === $extra) { + return null; + } + } + + throw new \UnexpectedValueException('Unsupported CBOR major type.'); + } + + /** + * Decodes a CBOR length. + * + * @since 2.13.2 + * @param string $data Binary CBOR data. + * @param int $offset Current offset. + * @param int $extra Additional info bits. + * @return int + * @throws \UnexpectedValueException When a CBOR length is invalid or unsupported. + */ + private function decode_cbor_length($data, &$offset, $extra) { + + if ($extra < 24) { + return $extra; + } + + if (24 === $extra) { + return ord($this->read_cbor_bytes($data, $offset, 1)); + } + + if (25 === $extra) { + $value = unpack('n', $this->read_cbor_bytes($data, $offset, 2)); + + return absint($value[1] ?? 0); + } + + if (26 === $extra) { + $value = unpack('N', $this->read_cbor_bytes($data, $offset, 4)); + + return absint($value[1] ?? 0); + } + + throw new \UnexpectedValueException('Unsupported CBOR length.'); + } + + /** + * Reads bytes from CBOR input. + * + * @since 2.13.2 + * @param string $data Binary CBOR data. + * @param int $offset Current offset. + * @param int $length Number of bytes. + * @return string + * @throws \UnexpectedValueException When the requested length exceeds the input. + */ + private function read_cbor_bytes($data, &$offset, $length) { + + if ($length < 0 || strlen($data) < ($offset + $length)) { + throw new \UnexpectedValueException('CBOR length exceeds data size.'); + } + + $bytes = substr($data, $offset, $length); + $offset += $length; + + return $bytes; + } + + /** + * Converts a COSE P-256 ES256 key to PEM. + * + * @since 2.13.2 + * @param array $key COSE key map. + * @return string|\WP_Error + */ + private function cose_key_to_pem($key) { + + if ( ! is_array($key)) { + return new \WP_Error('invalid_public_key', __('Unsupported passkey public key.', 'ultimate-multisite')); + } + + $key_type = $key[1] ?? null; + $algorithm = $key[3] ?? null; + $curve = $key[-1] ?? null; + $x = $key[-2] ?? ''; + $y = $key[-3] ?? ''; + + if (2 !== $key_type || -7 !== $algorithm || 1 !== $curve || 32 !== strlen($x) || 32 !== strlen($y)) { + return new \WP_Error('unsupported_public_key', __('Only ES256 passkeys are supported.', 'ultimate-multisite')); + } + + $algorithm_identifier = "\x30\x13\x06\x07\x2A\x86\x48\xCE\x3D\x02\x01\x06\x08\x2A\x86\x48\xCE\x3D\x03\x01\x07"; + $point = "\x04" . $x . $y; + $bit_string = "\x03" . $this->der_length(strlen($point) + 1) . "\x00" . $point; + $der = "\x30" . $this->der_length(strlen($algorithm_identifier . $bit_string)) . $algorithm_identifier . $bit_string; + + return "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END PUBLIC KEY-----\n"; // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + } + + /** + * Encodes a DER length. + * + * @since 2.13.2 + * @param int $length Length. + * @return string + */ + private function der_length($length) { + + if ($length < 128) { + return chr($length); + } + + $bytes = ''; + + while ($length > 0) { + $bytes = chr($length & 0xff) . $bytes; + $length = $length >> 8; + } + + return chr(0x80 | strlen($bytes)) . $bytes; + } +} diff --git a/inc/checkout/class-checkout.php b/inc/checkout/class-checkout.php index f6aa55c96..9a0cc4a73 100644 --- a/inc/checkout/class-checkout.php +++ b/inc/checkout/class-checkout.php @@ -3280,7 +3280,9 @@ public function register_scripts(): void { // Enqueue password styles (includes dashicons as dependency). wp_enqueue_style('wu-password'); - wp_register_script('wu-checkout', wu_get_asset('checkout.js', 'js'), ['jquery-core', 'wu-vue', 'moment', 'wu-block-ui', 'wu-functions', 'password-strength-meter', 'wu-password-strength', 'underscore', 'wp-polyfill', 'wp-hooks', 'wu-cookie-helpers', 'wu-password-toggle'], wu_get_version(), true); + \WP_Ultimo\Auth\Passwordless_Auth_Manager::get_instance()->enqueue_assets(); + + wp_register_script('wu-checkout', wu_get_asset('checkout.js', 'js'), ['jquery-core', 'wu-vue', 'moment', 'wu-block-ui', 'wu-functions', 'password-strength-meter', 'wu-password-strength', 'underscore', 'wp-polyfill', 'wp-hooks', 'wu-cookie-helpers', 'wu-password-toggle', 'wu-passwordless-auth'], wu_get_version(), true); wp_set_script_translations('wu-password-toggle', 'ultimate-multisite'); diff --git a/inc/class-wp-ultimo.php b/inc/class-wp-ultimo.php index 753aeb232..e6e80d874 100644 --- a/inc/class-wp-ultimo.php +++ b/inc/class-wp-ultimo.php @@ -560,6 +560,12 @@ protected function load_extra_components(): void { */ WP_Ultimo\UI\Thank_You_Element::get_instance(); WP_Ultimo\UI\Checkout_Element::get_instance(); + + /* + * Loads native passwordless/passkey authentication before login surfaces. + */ + WP_Ultimo\Auth\Passwordless_Auth_Manager::get_instance(); + WP_Ultimo\UI\Login_Form_Element::get_instance(); WP_Ultimo\UI\Simple_Text_Element::get_instance(); diff --git a/inc/database/email-otp-attempts/class-email-otp-attempts-schema.php b/inc/database/email-otp-attempts/class-email-otp-attempts-schema.php new file mode 100644 index 000000000..e33b85733 --- /dev/null +++ b/inc/database/email-otp-attempts/class-email-otp-attempts-schema.php @@ -0,0 +1,114 @@ + 'id', + 'type' => 'bigint', + 'length' => '20', + 'unsigned' => true, + 'extra' => 'auto_increment', + 'primary' => true, + 'sortable' => true, + ], + + [ + 'name' => 'email', + 'type' => 'varchar', + 'length' => '190', + 'searchable' => true, + ], + + [ + 'name' => 'user_id', + 'type' => 'bigint', + 'length' => '20', + 'unsigned' => true, + 'sortable' => true, + 'transition' => true, + ], + + [ + 'name' => 'token_hash', + 'type' => 'char', + 'length' => '64', + ], + + [ + 'name' => 'code_hash', + 'type' => 'varchar', + 'length' => '255', + ], + + [ + 'name' => 'ip_hash', + 'type' => 'char', + 'length' => '64', + ], + + [ + 'name' => 'attempts', + 'type' => 'tinyint', + 'length' => '4', + 'unsigned' => true, + 'sortable' => true, + ], + + [ + 'name' => 'expires_at', + 'type' => 'datetime', + 'default' => null, + 'date_query' => true, + 'sortable' => true, + 'allow_null' => true, + ], + + [ + 'name' => 'consumed_at', + 'type' => 'datetime', + 'default' => null, + 'date_query' => true, + 'sortable' => true, + 'allow_null' => true, + ], + + [ + 'name' => 'date_created', + 'type' => 'datetime', + 'default' => null, + 'created' => true, + 'date_query' => true, + 'sortable' => true, + 'allow_null' => true, + ], + + ]; +} diff --git a/inc/database/email-otp-attempts/class-email-otp-attempts-table.php b/inc/database/email-otp-attempts/class-email-otp-attempts-table.php new file mode 100644 index 000000000..30efcf0d5 --- /dev/null +++ b/inc/database/email-otp-attempts/class-email-otp-attempts-table.php @@ -0,0 +1,74 @@ +schema = "id bigint(20) NOT NULL auto_increment, + email varchar(190) NOT NULL default '', + user_id bigint(20) NOT NULL default '0', + token_hash char(64) NOT NULL, + code_hash varchar(255) NOT NULL default '', + ip_hash char(64) NOT NULL default '', + attempts tinyint(4) NOT NULL default '0', + expires_at datetime NULL, + consumed_at datetime NULL, + date_created datetime NULL, + PRIMARY KEY (id), + UNIQUE KEY token_hash (token_hash), + KEY email (email), + KEY user_id (user_id), + KEY ip_hash (ip_hash), + KEY expires_at (expires_at)"; + } +} diff --git a/inc/database/passkey-credentials/class-passkey-credentials-schema.php b/inc/database/passkey-credentials/class-passkey-credentials-schema.php new file mode 100644 index 000000000..c4c9ad3ad --- /dev/null +++ b/inc/database/passkey-credentials/class-passkey-credentials-schema.php @@ -0,0 +1,123 @@ + 'id', + 'type' => 'bigint', + 'length' => '20', + 'unsigned' => true, + 'extra' => 'auto_increment', + 'primary' => true, + 'sortable' => true, + ], + + [ + 'name' => 'user_id', + 'type' => 'bigint', + 'length' => '20', + 'unsigned' => true, + 'sortable' => true, + 'transition' => true, + ], + + [ + 'name' => 'credential_id', + 'type' => 'varchar', + 'length' => '255', + 'searchable' => true, + ], + + [ + 'name' => 'public_key', + 'type' => 'longtext', + 'default' => '', + ], + + [ + 'name' => 'sign_count', + 'type' => 'bigint', + 'length' => '20', + 'unsigned' => true, + 'sortable' => true, + ], + + [ + 'name' => 'aaguid', + 'type' => 'varchar', + 'length' => '64', + ], + + [ + 'name' => 'transports', + 'type' => 'varchar', + 'length' => '255', + ], + + [ + 'name' => 'active', + 'type' => 'tinyint', + 'length' => '1', + 'unsigned' => true, + 'sortable' => true, + ], + + [ + 'name' => 'date_created', + 'type' => 'datetime', + 'default' => null, + 'created' => true, + 'date_query' => true, + 'sortable' => true, + 'allow_null' => true, + ], + + [ + 'name' => 'date_updated', + 'type' => 'datetime', + 'default' => null, + 'modified' => true, + 'date_query' => true, + 'sortable' => true, + 'allow_null' => true, + ], + + [ + 'name' => 'date_last_used', + 'type' => 'datetime', + 'default' => null, + 'date_query' => true, + 'sortable' => true, + 'allow_null' => true, + ], + + ]; +} diff --git a/inc/database/passkey-credentials/class-passkey-credentials-table.php b/inc/database/passkey-credentials/class-passkey-credentials-table.php new file mode 100644 index 000000000..a01df36b7 --- /dev/null +++ b/inc/database/passkey-credentials/class-passkey-credentials-table.php @@ -0,0 +1,73 @@ +schema = "id bigint(20) NOT NULL auto_increment, + user_id bigint(20) NOT NULL default '0', + credential_id varchar(255) NOT NULL, + public_key longtext NOT NULL, + sign_count bigint(20) unsigned NOT NULL default '0', + aaguid varchar(64) NOT NULL default '', + transports varchar(255) NOT NULL default '', + active tinyint(1) NOT NULL default '1', + date_created datetime NULL, + date_updated datetime NULL, + date_last_used datetime NULL, + PRIMARY KEY (id), + UNIQUE KEY credential_id (credential_id), + KEY user_id (user_id), + KEY active (active)"; + } +} diff --git a/inc/database/webauthn-challenges/class-webauthn-challenges-schema.php b/inc/database/webauthn-challenges/class-webauthn-challenges-schema.php new file mode 100644 index 000000000..bf561cef1 --- /dev/null +++ b/inc/database/webauthn-challenges/class-webauthn-challenges-schema.php @@ -0,0 +1,105 @@ + 'id', + 'type' => 'bigint', + 'length' => '20', + 'unsigned' => true, + 'extra' => 'auto_increment', + 'primary' => true, + 'sortable' => true, + ], + + [ + 'name' => 'challenge_hash', + 'type' => 'char', + 'length' => '64', + ], + + [ + 'name' => 'type', + 'type' => 'enum(\'registration\', \'authentication\')', + 'default' => 'authentication', + ], + + [ + 'name' => 'user_id', + 'type' => 'bigint', + 'length' => '20', + 'unsigned' => true, + 'sortable' => true, + 'transition' => true, + ], + + [ + 'name' => 'rp_id', + 'type' => 'varchar', + 'length' => '255', + ], + + [ + 'name' => 'origin', + 'type' => 'varchar', + 'length' => '255', + ], + + [ + 'name' => 'expires_at', + 'type' => 'datetime', + 'default' => null, + 'date_query' => true, + 'sortable' => true, + 'allow_null' => true, + ], + + [ + 'name' => 'used_at', + 'type' => 'datetime', + 'default' => null, + 'date_query' => true, + 'sortable' => true, + 'allow_null' => true, + ], + + [ + 'name' => 'date_created', + 'type' => 'datetime', + 'default' => null, + 'created' => true, + 'date_query' => true, + 'sortable' => true, + 'allow_null' => true, + ], + + ]; +} diff --git a/inc/database/webauthn-challenges/class-webauthn-challenges-table.php b/inc/database/webauthn-challenges/class-webauthn-challenges-table.php new file mode 100644 index 000000000..4c472bc97 --- /dev/null +++ b/inc/database/webauthn-challenges/class-webauthn-challenges-table.php @@ -0,0 +1,72 @@ +schema = "id bigint(20) NOT NULL auto_increment, + challenge_hash char(64) NOT NULL, + type enum('registration', 'authentication') NOT NULL, + user_id bigint(20) NOT NULL default '0', + rp_id varchar(255) NOT NULL default '', + origin varchar(255) NOT NULL default '', + expires_at datetime NULL, + used_at datetime NULL, + date_created datetime NULL, + PRIMARY KEY (id), + UNIQUE KEY challenge_hash (challenge_hash), + KEY user_id (user_id), + KEY type (type), + KEY expires_at (expires_at)"; + } +} diff --git a/inc/functions/helper.php b/inc/functions/helper.php index c6fd053ca..1b64b13e5 100644 --- a/inc/functions/helper.php +++ b/inc/functions/helper.php @@ -269,11 +269,11 @@ function wu_get_function_caller(int $depth = 1): ?string { */ function wu_cli_is_plugin_skipped($plugin = null): bool { - if ( ! defined('WP_CLI') || ! WP_CLI) { + if ( ! defined('WP_CLI') || ! WP_CLI || ! is_callable(['WP_CLI', 'get_config'])) { return false; } - $skipped_plugins = \WP_CLI::get_config('skip-plugins'); + $skipped_plugins = call_user_func(['WP_CLI', 'get_config'], 'skip-plugins'); if (is_bool($skipped_plugins)) { return true; @@ -349,6 +349,7 @@ function wu_kses_allowed_html(): array { 'mask' => true, 'filter' => true, 'aria-hidden' => true, + 'aria-live' => true, 'aria-labelledby' => true, 'aria-describedby' => true, 'role' => true, @@ -409,6 +410,8 @@ function wu_kses_allowed_html(): array { '@click.prevent' => true, '@submit' => true, '@change' => true, + 'aria-live' => true, + 'role' => true, // Common data attributes 'data-image' => true, 'data-src' => true, @@ -456,23 +459,34 @@ function wu_kses_allowed_html(): array { 'data-include' => true, 'data-clipboard-action' => true, 'data-action' => true, + 'data-context' => true, + 'data-identifier-field' => true, + 'data-redirect-to' => true, + 'data-redirect-type' => true, + 'data-success' => true, + 'data-wu-step' => true, // others 'style' => true, 'class' => true, 'id' => true, + 'hidden' => true, 'data-price' => true, ]; $allowed_html = wp_kses_allowed_html('post'); $allowed_html['input'] = [ - 'value' => true, - 'min' => true, - 'max' => true, - 'type' => true, - 'placeholder' => true, - 'name' => true, - 'disabled' => true, - 'checked' => true, + 'autocomplete' => true, + 'value' => true, + 'inputmode' => true, + 'maxlength' => true, + 'min' => true, + 'max' => true, + 'pattern' => true, + 'type' => true, + 'placeholder' => true, + 'name' => true, + 'disabled' => true, + 'checked' => true, ]; $allowed_html['textarea'] = [ 'name' => true, @@ -491,6 +505,8 @@ function wu_kses_allowed_html(): array { 'value' => true, ]; $allowed_html['button'] = [ + 'class' => true, + 'id' => true, 'type' => true, 'disabled' => true, 'name' => true, diff --git a/inc/loaders/class-table-loader.php b/inc/loaders/class-table-loader.php index 0fef8f100..d497f9e04 100644 --- a/inc/loaders/class-table-loader.php +++ b/inc/loaders/class-table-loader.php @@ -185,6 +185,30 @@ class Table_Loader { */ public $checkout_formmeta_table; + /** + * The Passkey Credentials Table. + * + * @since 2.13.2 + * @var \WP_Ultimo\Database\Passkey_Credentials\Passkey_Credentials_Table + */ + public $passkey_credential_table; + + /** + * The WebAuthn Challenges Table. + * + * @since 2.13.2 + * @var \WP_Ultimo\Database\WebAuthn_Challenges\WebAuthn_Challenges_Table + */ + public $webauthn_challenge_table; + + /** + * The Email OTP Attempts Table. + * + * @since 2.13.2 + * @var \WP_Ultimo\Database\Email_OTP_Attempts\Email_OTP_Attempts_Table + */ + public $email_otp_attempt_table; + /** * Loads the table objects for our custom tables. * @@ -255,6 +279,13 @@ public function init(): void { */ $this->checkout_form_table = new \WP_Ultimo\Database\Checkout_Forms\Checkout_Forms_Table(); $this->checkout_formmeta_table = new \WP_Ultimo\Database\Checkout_Forms\Checkout_Forms_Meta_Table(); + + /** + * Loads passwordless authentication tables. + */ + $this->passkey_credential_table = new \WP_Ultimo\Database\Passkey_Credentials\Passkey_Credentials_Table(); + $this->webauthn_challenge_table = new \WP_Ultimo\Database\WebAuthn_Challenges\WebAuthn_Challenges_Table(); + $this->email_otp_attempt_table = new \WP_Ultimo\Database\Email_OTP_Attempts\Email_OTP_Attempts_Table(); } /** diff --git a/inc/ui/class-login-form-element.php b/inc/ui/class-login-form-element.php index d20110563..7b99535b7 100644 --- a/inc/ui/class-login-form-element.php +++ b/inc/ui/class-login-form-element.php @@ -313,6 +313,8 @@ public function register_scripts() { wp_set_script_translations('wu-password-toggle', 'ultimate-multisite'); + \WP_Ultimo\Auth\Passwordless_Auth_Manager::get_instance()->enqueue_assets(); + // Enqueue password strength scripts for reset password page. if ($this->is_reset_password_page()) { // wu-password-strength is globally registered with password-strength-meter as dependency. @@ -774,59 +776,30 @@ public function output($atts, $content = null) { } else { $view = 'dashboard-widgets/login-form'; - $fields = [ - 'log' => [ - 'type' => 'text', - 'title' => $atts['label_username'], - 'placeholder' => $atts['placeholder_username'], - 'tooltip' => '', - 'html_attr' => [ - 'autocomplete' => 'username', - ], - ], - 'pwd' => [ - 'type' => 'password', - 'title' => $atts['label_password'], - 'placeholder' => $atts['placeholder_password'], - 'tooltip' => '', - 'html_attr' => [ - 'autocomplete' => 'current-password', - ], - ], - ]; - - if ($atts['remember']) { - $fields['rememberme'] = [ - 'type' => 'toggle', - 'title' => $atts['label_remember'], - 'desc' => $atts['desc_remember'], - ]; - } - - $fields['redirect_to'] = [ - 'type' => 'hidden', - 'value' => isset($_GET['redirect_to']) ? sanitize_url(wp_unslash($_GET['redirect_to'])) : $atts['redirect'], // phpcs:ignore WordPress.Security.NonceVerification.Recommended - ]; + $redirect_to = isset($_GET['redirect_to']) ? sanitize_url(wp_unslash($_GET['redirect_to'])) : $atts['redirect']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if (isset($_GET['redirect_to'])) { // phpcs:ignore WordPress.Security.NonceVerification $atts['redirect_type'] = 'query_redirect'; } elseif ('customer_site' === $atts['redirect_type']) { - $fields['redirect_to']['value'] = $atts['customer_redirect_path']; + $redirect_to = $atts['customer_redirect_path']; } elseif ('main_site' === $atts['redirect_type']) { - $fields['redirect_to']['value'] = $atts['main_redirect_path']; + $redirect_to = $atts['main_redirect_path']; } - $fields['wu_login_form_redirect_type'] = [ - 'type' => 'hidden', - 'value' => $atts['redirect_type'], - ]; - - $fields['wp-submit'] = [ - 'type' => 'submit', - 'title' => $atts['label_log_in'], - 'value' => $atts['label_log_in'], - 'classes' => 'button button-primary wu-w-full', - 'wrapper_classes' => 'wu-items-end wu-bg-none', + $fields = [ + 'passwordless_login' => [ + 'type' => 'html', + 'content' => \WP_Ultimo\Auth\Passwordless_Auth_Manager::get_instance()->get_login_form_markup( + [ + 'context' => 'login-form', + 'redirect_to' => $redirect_to, + 'redirect_type' => $atts['redirect_type'], + 'fallback_url' => add_query_arg('wu_password_fallback', '1', wp_login_url($redirect_to)), + ] + ), + 'classes' => '', + 'wrapper_classes' => 'wu-w-full wu-bg-none', + ], ]; /* diff --git a/readme.txt b/readme.txt index b6993d7de..145045ccd 100644 --- a/readme.txt +++ b/readme.txt @@ -42,6 +42,7 @@ Everything you need to build and scale a WordPress Multisite SaaS platform: - **Flexible Plans & Limits** – Package features and enforce quotas across your multisite network - **Template Library** – High-converting site templates customers can launch in minutes - **Customer Dashboard** – Branded UI for managing billing, sites, domains, and settings +- **Passwordless Login** – Passkey sign-in and short-lived email codes across login and checkout flows - **White-Label Ready** – Rename, rebrand, and customize the experience - **Hosting Integrations** – Cloudflare, GridPane, Cloudways, WPMU DEV, and more - **Developer-Friendly** – Hooks, filters, and an extensible add-on system @@ -99,6 +100,10 @@ Yes. Ultimate Multisite includes robust domain mapping with automated DNS verifi Stripe, PayPal, and manual payments are supported out of the box. += Does Ultimate Multisite support passwordless login? = + +Yes. Ultimate Multisite includes native passwordless login with passkeys when the browser supports WebAuthn and short-lived email one-time codes as a fallback. + = Can I migrate from WP Ultimo? = Yes. Ultimate Multisite is a community-maintained fork of WP Ultimo 2.x. Migration happens automatically when the plugin is activated. diff --git a/tests/WP_Ultimo/Auth/Passwordless_Auth_Test.php b/tests/WP_Ultimo/Auth/Passwordless_Auth_Test.php new file mode 100644 index 000000000..983d67602 --- /dev/null +++ b/tests/WP_Ultimo/Auth/Passwordless_Auth_Test.php @@ -0,0 +1,198 @@ +install_auth_tables(); + $this->truncate_auth_tables(); + } + + /** + * Cleans up filters and rows after each test. + */ + public function tear_down() { + + remove_all_filters('wu_passwordless_otp_code'); + remove_all_filters('wu_passwordless_should_send_otp'); + + $this->truncate_auth_tables(); + + parent::tear_down(); + } + + /** + * Tests OTPs are stored hashed and consumed once. + */ + public function test_otp_codes_are_hashed_and_single_use() { + + $user = self::factory()->user->create_and_get( + [ + 'user_email' => 'passwordless@example.test', + ] + ); + + add_filter( + 'wu_passwordless_otp_code', + function () { + return '123456'; + } + ); + + add_filter('wu_passwordless_should_send_otp', '__return_false'); + + $service = new Email_OTP_Service(); + $created = $service->create_and_send($user, $user->user_email); + + $this->assertIsArray($created); + $this->assertNotEmpty($created['token']); + + $row = $service->get_attempt_by_token($created['token']); + + $this->assertNotNull($row); + $this->assertStringNotContainsString('123456', $row->code_hash); + $this->assertTrue(wp_check_password('123456', $row->code_hash)); + + $verified = $service->verify($created['token'], '123456'); + + $this->assertInstanceOf(\WP_User::class, $verified); + $this->assertSame($user->ID, $verified->ID); + + $second_try = $service->verify($created['token'], '123456'); + + $this->assertWPError($second_try); + } + + /** + * Tests WebAuthn challenges are stored hashed and single-use. + */ + public function test_webauthn_challenges_are_hashed_and_single_use() { + + $store = new WebAuthn_Challenge_Store(); + $challenge = $store->create('authentication', 123, 'example.test', 'https://example.test'); + + $this->assertNotEmpty($challenge); + + $row = $store->get_valid($challenge, 'authentication'); + + $this->assertNotNull($row); + $this->assertSame(hash('sha256', $challenge), $row->challenge_hash); + $this->assertStringNotContainsString($challenge, $row->challenge_hash); + + $this->assertTrue($store->mark_used((int) $row->id)); + + $this->assertNull($store->get_valid($challenge, 'authentication')); + $this->assertFalse($store->mark_used((int) $row->id)); + } + + /** + * Tests passkey credentials can be stored and usage counters updated. + */ + public function test_passkey_credentials_can_be_stored_and_updated() { + + $store = new Passkey_Credential_Store(); + + $created = $store->create( + 123, + 'credential-id', + "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----\n", + 1, + str_repeat('a', 32), + ['internal'] + ); + + $this->assertTrue($created); + $this->assertTrue($store->user_has_credentials(123)); + + $credential = $store->find_by_credential_id('credential-id'); + + $this->assertNotNull($credential); + $this->assertSame('credential-id', $credential->credential_id); + $this->assertSame('internal', $credential->transports); + + $this->assertTrue($store->update_usage((int) $credential->id, 7)); + + $updated = $store->find_by_credential_id('credential-id'); + + $this->assertSame(7, (int) $updated->sign_count); + $this->assertNotEmpty($updated->date_last_used); + } + + /** + * Installs auth tables. + */ + protected function install_auth_tables() { + + $tables = [ + new Passkey_Credentials_Table(), + new WebAuthn_Challenges_Table(), + new Email_OTP_Attempts_Table(), + ]; + + foreach ($tables as $table) { + if ( ! $this->table_exists($table->table_name)) { + $table->install(); + } + } + } + + /** + * Checks if a test table exists. + * + * @param string $table_name Table name. + * @return bool + */ + protected function table_exists($table_name) { + + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $found = $wpdb->get_var( + $wpdb->prepare('SHOW TABLES LIKE %s', sanitize_key($table_name)) + ); + + return $found === $table_name; + } + + /** + * Truncates auth tables. + */ + protected function truncate_auth_tables() { + + global $wpdb; + + $tables = [ + (new Passkey_Credential_Store())->get_table_name(), + (new WebAuthn_Challenge_Store())->get_table_name(), + (new Email_OTP_Service())->get_table_name(), + ]; + + foreach ($tables as $table) { + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query("TRUNCATE TABLE {$table}"); + } + } +} diff --git a/tests/e2e/cypress/integration/login.spec.js b/tests/e2e/cypress/integration/login.spec.js index 3fbee067a..70e794757 100644 --- a/tests/e2e/cypress/integration/login.spec.js +++ b/tests/e2e/cypress/integration/login.spec.js @@ -1,5 +1,12 @@ describe("Login", () => { describe("User Interface", () => { + it("Should show the email-first passwordless login form", () => { + cy.visit("/wp-login.php"); + cy.get(".wu-passwordless-auth").should("be.visible"); + cy.get(".wu-passwordless-email").should("be.visible"); + cy.get("#user_pass").should("not.be.visible"); + }); + it("Should be able to login by the user interface", () => { cy.loginByForm( Cypress.env("admin").username, diff --git a/tests/e2e/cypress/support/commands/login.js b/tests/e2e/cypress/support/commands/login.js index ff6ced5ad..7ddf05f49 100644 --- a/tests/e2e/cypress/support/commands/login.js +++ b/tests/e2e/cypress/support/commands/login.js @@ -43,6 +43,19 @@ Cypress.Commands.add("loginByForm", (username, password) => { } cy.get("body").then(($body) => { + if ($body.find(".wu-passwordless-auth").length) { + cy.get(".wu-passwordless-auth").should("be.visible"); + cy.get(".wu-passwordless-email").should("be.visible"); + if ($body.find("#user_pass").length) { + cy.get("#user_pass").should("not.be.visible"); + } else { + cy.get("#user_pass").should("not.exist"); + } + cy.loginByApi(username, password); + cy.visit("/wp-admin/"); + return; + } + if (!$body.find("#user_login").length) { cy.loginByApi(username, password); cy.visit("/wp-admin/"); diff --git a/views/checkout/partials/inline-login-prompt.php b/views/checkout/partials/inline-login-prompt.php index 200995f89..9d0d33222 100644 --- a/views/checkout/partials/inline-login-prompt.php +++ b/views/checkout/partials/inline-login-prompt.php @@ -16,27 +16,16 @@+
+ +
-