diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 1f039866..5d7f993d 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -125,6 +125,9 @@ public static function add_hooks( $compat ) { add_filter( 'wpmu_users_columns', array( __CLASS__, 'filter_manage_users_columns' ) ); add_filter( 'manage_users_custom_column', array( __CLASS__, 'manage_users_custom_column' ), 10, 3 ); + // 0. Intercept passkey authentications to bypass password requirement (priority 20). + add_filter( 'authenticate', array( __CLASS__, 'filter_authenticate_passkey' ), 20, 3 ); + // 1. Prevent WP core from sending login cookies after username/password authentication (priority 30). add_filter( 'authenticate', array( __CLASS__, 'filter_authenticate' ), 31 ); @@ -261,6 +264,7 @@ private static function get_default_providers() { 'Two_Factor_Email' => TWO_FACTOR_DIR . 'providers/class-two-factor-email.php', 'Two_Factor_Totp' => TWO_FACTOR_DIR . 'providers/class-two-factor-totp.php', 'Two_Factor_Backup_Codes' => TWO_FACTOR_DIR . 'providers/class-two-factor-backup-codes.php', + 'Two_Factor_Passkey' => TWO_FACTOR_DIR . 'providers/class-two-factor-passkey.php', 'Two_Factor_Dummy' => TWO_FACTOR_DIR . 'providers/class-two-factor-dummy.php', ); } @@ -906,6 +910,57 @@ public static function destroy_current_session_for_user( $user ) { } } + /** + * Intercept login to process Passwordless Passkey authentications. + * + * If a passkey payload is present, it validates it and returns the WP_User, + * bypassing the standard password check. + * + * @since 0.17.0 + * + * @param WP_User|WP_Error|null $user WP_User or WP_Error object from a previous callback. Default null. + * @param string $username Username for authentication. + * @param string $password Password for authentication. + * @return WP_User|WP_Error|null WP_User on success, WP_Error on failure, or unchanged if not a passkey request. + */ + public static function filter_authenticate_passkey( $user, $username, $password ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( empty( $_POST['two_factor_passkey_assertion'] ) || empty( $username ) ) { + return $user; + } + + $passkey_provider = self::get_providers()['Two_Factor_Passkey'] ?? null; + if ( ! $passkey_provider ) { + return $user; + } + + // Find the user by username or email. + $user_obj = get_user_by( 'login', $username ); + if ( ! $user_obj ) { + $user_obj = get_user_by( 'email', $username ); + } + + if ( ! $user_obj || ! $passkey_provider->is_available_for_user( $user_obj ) ) { + return new WP_Error( 'invalid_passkey', __( 'No passkeys found for this user.', 'two-factor' ) ); + } + + // Delegate validation to the provider. + // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $assertion_json = wp_unslash( $_POST['two_factor_passkey_assertion'] ); + $is_valid = $passkey_provider->validate_passwordless_assertion( $user_obj, $assertion_json ); + + if ( is_wp_error( $is_valid ) ) { + return $is_valid; + } elseif ( $is_valid ) { + // Passkeys satisfy both factors. Bypass the Two-Factor UI enforcement. + remove_filter( 'authenticate', array( __CLASS__, 'filter_authenticate' ), 31 ); + remove_action( 'wp_login', array( __CLASS__, 'wp_login' ), PHP_INT_MAX ); + return $user_obj; + } + + return new WP_Error( 'invalid_passkey', __( 'Invalid Passkey authentication.', 'two-factor' ) ); + } + /** * Disable WP core login cookies for users that require second factor. Disable * authenticated API requests unless explicitly enabled for the user (disabled by default). diff --git a/composer.json b/composer.json index c79584a5..725e2171 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,8 @@ "minimum-stability": "dev", "prefer-stable" : true, "require": { - "php": ">=7.2.24|^8" + "php": ">=7.2.24|^8", + "lbuchs/webauthn": "^2.0" }, "require-dev": { "automattic/vipwpcs": "^3.0", diff --git a/composer.lock b/composer.lock index dbe2a533..8ea6f9a4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,54 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c3df3fd602fb474fac8ef78f583ae835", - "packages": [], + "content-hash": "1ac58ccb80763aeb3149f6e0c06bc657", + "packages": [ + { + "name": "lbuchs/webauthn", + "version": "v2.0.0", + "source": { + "type": "git", + "url": "https://github.com/lbuchs/WebAuthn.git", + "reference": "b31384c90ceb18bf0fad2755eef77db049cc9593" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lbuchs/WebAuthn/zipball/b31384c90ceb18bf0fad2755eef77db049cc9593", + "reference": "b31384c90ceb18bf0fad2755eef77db049cc9593", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "lbuchs\\WebAuthn\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lukas Buchs", + "role": "Developer" + } + ], + "description": "A simple PHP WebAuthn (FIDO2) server library", + "homepage": "https://github.com/lbuchs/webauthn", + "keywords": [ + "Authentication", + "webauthn" + ], + "support": { + "issues": "https://github.com/lbuchs/WebAuthn/issues", + "source": "https://github.com/lbuchs/WebAuthn/tree/v2.0.0" + }, + "time": "2023-03-24T07:49:05+00:00" + } + ], "packages-dev": [ { "name": "automattic/vipwpcs", diff --git a/js/passkeys.js b/js/passkeys.js new file mode 100644 index 00000000..7d7446ac --- /dev/null +++ b/js/passkeys.js @@ -0,0 +1,235 @@ +// Passkeys frontend implementation +document.addEventListener('DOMContentLoaded', function() { + + // Registration Flow + const registerBtn = document.getElementById('two-factor-passkey-register-btn'); + if (registerBtn) { + registerBtn.addEventListener('click', async function() { + try { + registerBtn.disabled = true; + registerBtn.textContent = 'Registering...'; + + // 1. Get creation options from server + const optionsRes = await fetch(twoFactorPasskeyData.restUrl + 'passkeys/options?action=register', { + method: 'GET', + headers: { 'X-WP-Nonce': twoFactorPasskeyData.nonce } + }); + const options = await optionsRes.json(); + if (!optionsRes.ok) throw new Error(options.message || 'Failed to get options'); + + // 2. Decode options (Base64Url to Uint8Array) + const createArgs = recursiveBase64StrToArrayBuffer(options); + + // 3. Prompt user to create passkey + const credential = await navigator.credentials.create(createArgs); + + // 4. Encode response + const data = { + id: credential.id, + rawId: arrayBufferToBase64(credential.rawId), + type: credential.type, + response: { + attestationObject: arrayBufferToBase64(credential.response.attestationObject), + clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON) + } + }; + + // 5. Send to server to register + const regRes = await fetch(twoFactorPasskeyData.restUrl + 'passkeys/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': twoFactorPasskeyData.nonce + }, + body: JSON.stringify(data) + }); + + const regData = await regRes.json(); + if (!regRes.ok) throw new Error(regData.message || 'Registration failed'); + + alert('Passkey registered successfully!'); + window.location.reload(); + + } catch (err) { + console.error(err); + alert('Passkey registration failed: ' + err.message); + } finally { + registerBtn.disabled = false; + registerBtn.textContent = 'Register New Passkey'; + } + }); + } + + // Login Flow (Option 1 - Passwordless) + // For passwordless, we need to intercept the standard login form. + const loginForm = document.getElementById('loginform'); + if (loginForm && !document.getElementById('twofactorform')) { + const userLoginInput = document.getElementById('user_login'); + + // Add a "Login with Passkey" button + const passkeyLoginBtn = document.createElement('button'); + passkeyLoginBtn.type = 'button'; + passkeyLoginBtn.className = 'button button-secondary button-large'; + passkeyLoginBtn.style.marginTop = '10px'; + passkeyLoginBtn.style.width = '100%'; + passkeyLoginBtn.textContent = 'Login with Passkey'; + + // Insert after the submit button + const submitP = loginForm.querySelector('.submit'); + if (submitP) { + submitP.appendChild(passkeyLoginBtn); + } + + passkeyLoginBtn.addEventListener('click', async function(e) { + e.preventDefault(); + const username = userLoginInput.value.trim(); + if (!username) { + alert('Please enter your username or email address first.'); + userLoginInput.focus(); + return; + } + + try { + passkeyLoginBtn.disabled = true; + passkeyLoginBtn.textContent = 'Authenticating...'; + + const optionsRes = await fetch(twoFactorPasskeyData.restUrl + 'passkeys/options?action=authenticate&username=' + encodeURIComponent(username)); + const options = await optionsRes.json(); + if (!optionsRes.ok) throw new Error(options.message || 'Failed to get options'); + + const sessionId = options.session_id; + const getArgs = recursiveBase64StrToArrayBuffer(options.args); + const credential = await navigator.credentials.get(getArgs); + + const assertion = { + id: credential.id, + rawId: arrayBufferToBase64(credential.rawId), + type: credential.type, + session_id: sessionId, + response: { + authenticatorData: arrayBufferToBase64(credential.response.authenticatorData), + clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON), + signature: arrayBufferToBase64(credential.response.signature), + userHandle: credential.response.userHandle ? arrayBufferToBase64(credential.response.userHandle) : null + } + }; + + let assertionInput = document.getElementById('two_factor_passkey_assertion'); + if (!assertionInput) { + assertionInput = document.createElement('input'); + assertionInput.type = 'hidden'; + assertionInput.id = 'two_factor_passkey_assertion'; + assertionInput.name = 'two_factor_passkey_assertion'; + loginForm.appendChild(assertionInput); + } + assertionInput.value = JSON.stringify(assertion); + + HTMLFormElement.prototype.submit.call(loginForm); + + } catch (err) { + console.error(err); + alert('Passkey login failed: ' + err.message); + passkeyLoginBtn.disabled = false; + passkeyLoginBtn.textContent = 'Login with Passkey'; + } + }); + } + + // Login Flow (Option 2 - 2FA Interstitial) + const authBtn2FA = document.getElementById('two-factor-passkey-auth-btn'); + if (authBtn2FA) { + const twoFactorForm = authBtn2FA.closest('form'); + authBtn2FA.addEventListener('click', async function(e) { + e.preventDefault(); + const username = authBtn2FA.getAttribute('data-username'); + if (!username) return; + + try { + authBtn2FA.disabled = true; + authBtn2FA.textContent = 'Authenticating...'; + + const optionsRes = await fetch(twoFactorPasskeyData.restUrl + 'passkeys/options?action=authenticate&username=' + encodeURIComponent(username)); + const options = await optionsRes.json(); + if (!optionsRes.ok) throw new Error(options.message || 'Failed to get options'); + + const sessionId = options.session_id; + const getArgs = recursiveBase64StrToArrayBuffer(options.args); + const credential = await navigator.credentials.get(getArgs); + + const assertion = { + id: credential.id, + rawId: arrayBufferToBase64(credential.rawId), + type: credential.type, + session_id: sessionId, + response: { + authenticatorData: arrayBufferToBase64(credential.response.authenticatorData), + clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON), + signature: arrayBufferToBase64(credential.response.signature), + userHandle: credential.response.userHandle ? arrayBufferToBase64(credential.response.userHandle) : null + } + }; + + let assertionInput = document.getElementById('two_factor_passkey_assertion'); + if (!assertionInput) { + assertionInput = document.createElement('input'); + assertionInput.type = 'hidden'; + assertionInput.id = 'two_factor_passkey_assertion'; + assertionInput.name = 'two_factor_passkey_assertion'; + twoFactorForm.appendChild(assertionInput); + } + assertionInput.value = JSON.stringify(assertion); + + HTMLFormElement.prototype.submit.call(twoFactorForm); + + } catch (err) { + console.error(err); + alert('Passkey authentication failed: ' + err.message); + authBtn2FA.disabled = false; + authBtn2FA.textContent = 'Use Passkey'; + } + }); + } + + // Helpers for Base64Url + function arrayBufferToBase64(buffer) { + let binary = ''; + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + } + + function base64ToArrayBuffer(base64) { + base64 = base64.replace(/-/g, "+").replace(/_/g, "/"); + const padLen = (4 - (base64.length % 4)) % 4; + base64 += "=".repeat(padLen); + const binary_string = window.atob(base64); + const len = binary_string.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binary_string.charCodeAt(i); + } + return bytes.buffer; + } + + function recursiveBase64StrToArrayBuffer(obj) { + let prefix = '=?BINARY?B?'; + let suffix = '?='; + if (typeof obj === 'object' && obj !== null) { + for (let key in obj) { + if (typeof obj[key] === 'string') { + let str = obj[key]; + if (str.substring(0, prefix.length) === prefix && str.substring(str.length - suffix.length) === suffix) { + str = str.substring(prefix.length, str.length - suffix.length); + obj[key] = base64ToArrayBuffer(str); + } + } else { + recursiveBase64StrToArrayBuffer(obj[key]); + } + } + } + return obj; + } +}); diff --git a/providers/class-two-factor-passkey.php b/providers/class-two-factor-passkey.php new file mode 100644 index 00000000..1802d3de --- /dev/null +++ b/providers/class-two-factor-passkey.php @@ -0,0 +1,300 @@ + esc_url_raw( rest_url( Two_Factor_Core::REST_NAMESPACE . '/' ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + ) ); + } + + /** + * Helper to configure WebAuthn + */ + private function get_webauthn() { + return new WebAuthn('Two Factor', wp_parse_url( site_url(), PHP_URL_HOST ), array( 'none' )); + } + + public function register_rest_routes() { + register_rest_route( + Two_Factor_Core::REST_NAMESPACE, + '/passkeys/options', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'rest_get_options' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'action' => array( + 'required' => true, + 'type' => 'string', + ), + 'username' => array( + 'required' => false, + 'type' => 'string', + ), + ), + ), + ) + ); + + register_rest_route( + Two_Factor_Core::REST_NAMESPACE, + '/passkeys/register', + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'rest_register_passkey' ), + 'permission_callback' => 'is_user_logged_in', + ), + ) + ); + } + + public function rest_get_options( $request ) { + $action = $request->get_param( 'action' ); + $webauthn = $this->get_webauthn(); + + if ( 'register' === $action ) { + if ( ! is_user_logged_in() ) { + return new WP_Error( 'unauthorized', 'Must be logged in to register.', array( 'status' => 401 ) ); + } + $user = wp_get_current_user(); + + $createArgs = $webauthn->getCreateArgs( (string) $user->ID, $user->user_login, $user->display_name ); + $challenge = $webauthn->getChallenge(); + if ( is_object( $challenge ) && method_exists( $challenge, 'getBinaryString' ) ) { + $challenge_str = $challenge->getBinaryString(); + } else { + $challenge_str = (string) $challenge; + } + set_transient( 'webauthn_challenge_' . $user->ID, base64_encode( $challenge_str ), 15 * MINUTE_IN_SECONDS ); + + return rest_ensure_response( $createArgs ); + } + + if ( 'authenticate' === $action ) { + $username = $request->get_param( 'username' ); + $user = get_user_by( 'login', $username ) ?: get_user_by( 'email', $username ); + + if ( ! $user ) { + // Prevent username enumeration by returning mock options + $getArgs = $webauthn->getGetArgs(); + return rest_ensure_response( array( 'args' => $getArgs, 'session_id' => 'mock' ) ); + } + + $keys = get_user_meta( $user->ID, self::PASSKEYS_META_KEY, true ); + if ( empty( $keys ) ) { + $getArgs = $webauthn->getGetArgs(); + return rest_ensure_response( array( 'args' => $getArgs, 'session_id' => 'mock' ) ); + } + + // Pass existing credentials to restrict authentication to registered keys + $credentialIds = array(); + foreach ( $keys as $key ) { + $credentialIds[] = hex2bin( $key['id'] ); + } + $getArgs = $webauthn->getGetArgs( $credentialIds ); + $challenge = $webauthn->getChallenge(); + if ( is_object( $challenge ) && method_exists( $challenge, 'getBinaryString' ) ) { + $challenge_str = $challenge->getBinaryString(); + } else { + $challenge_str = (string) $challenge; + } + + $session_id = wp_generate_password( 20, false ); + set_transient( 'webauthn_login_' . $session_id, array( 'challenge' => base64_encode( $challenge_str ), 'user_id' => $user->ID ), 15 * MINUTE_IN_SECONDS ); + + return rest_ensure_response( array( 'args' => $getArgs, 'session_id' => $session_id ) ); + } + + return new WP_Error( 'invalid_action', 'Invalid action', array( 'status' => 400 ) ); + } + + public function rest_register_passkey( $request ) { + $user = wp_get_current_user(); + $challenge_b64 = get_transient( 'webauthn_challenge_' . $user->ID ); + if ( ! $challenge_b64 ) { + return new WP_Error( 'expired_challenge', 'Challenge expired', array( 'status' => 400 ) ); + } + delete_transient( 'webauthn_challenge_' . $user->ID ); + $challenge = base64_decode( $challenge_b64 ); + + $webauthn = $this->get_webauthn(); + $client_data_json = base64_decode( strtr( $request->get_param( 'response' )['clientDataJSON'], '-_', '+/' ) ); + $attestation_object = base64_decode( strtr( $request->get_param( 'response' )['attestationObject'], '-_', '+/' ) ); + + try { + $data = $webauthn->processCreate( $client_data_json, $attestation_object, $challenge, true, true, false ); + + $keys = get_user_meta( $user->ID, self::PASSKEYS_META_KEY, true ) ?: array(); + $keys[] = array( + 'id' => bin2hex($data->credentialId), + 'publicKey' => bin2hex($data->credentialPublicKey), + 'signatureCounter' => $data->signatureCounter, + ); + update_user_meta( $user->ID, self::PASSKEYS_META_KEY, $keys ); + + // Automatically enable the provider now that it's configured. + Two_Factor_Core::enable_provider_for_user( $user->ID, 'Two_Factor_Passkey' ); + + return rest_ensure_response( array( 'success' => true ) ); + } catch ( Exception $e ) { + error_log( 'WebAuthn Registration Error: ' . $e->getMessage() ); + return new WP_Error( 'webauthn_error', $e->getMessage(), array( 'status' => 400 ) ); + } + } + + public function is_available_for_user( $user ) { + $keys = get_user_meta( $user->ID, self::PASSKEYS_META_KEY, true ); + return ! empty( $keys ); + } + + public function authentication_page( $user ) { + // Option 2 stub, the JS handles Option 1 currently. + ?> +
++ +
+ ID ) { + return new WP_Error( 'invalid_session', 'Invalid or expired passkey session.' ); + } + delete_transient( 'webauthn_login_' . $assertion['session_id'] ); + + $webauthn = $this->get_webauthn(); + $keys = get_user_meta( $user->ID, self::PASSKEYS_META_KEY, true ); + $matched_key = null; + + $credentialId = base64_decode( $assertion['rawId'] ); + foreach ( $keys as $key ) { + if ( hex2bin( $key['id'] ) === $credentialId ) { + $matched_key = $key; + break; + } + } + + if ( ! $matched_key ) { + return new WP_Error( 'key_not_found', 'Credential not found for this user.' ); + } + + try { + $client_data_json = base64_decode( strtr( $assertion['response']['clientDataJSON'], '-_', '+/' ) ); + $authenticator_data = base64_decode( strtr( $assertion['response']['authenticatorData'], '-_', '+/' ) ); + $signature = base64_decode( strtr( $assertion['response']['signature'], '-_', '+/' ) ); + $public_key = hex2bin( $matched_key['publicKey'] ); + + $webauthn->processGet( + $client_data_json, + $authenticator_data, + $signature, + $public_key, + base64_decode( $session_data['challenge'] ) + ); + + return true; + } catch ( Exception $e ) { + return new WP_Error( 'webauthn_error', $e->getMessage() ); + } + } + + /** + * Validate the authentication for this provider. + * + * @since 0.17.0 + * + * @param WP_User $user WP_User object of the logged-in user. + * @return boolean + */ + public function validate_authentication( $user ) { + if ( empty( $_POST['two_factor_passkey_assertion'] ) ) { + return false; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $assertion_json = wp_unslash( $_POST['two_factor_passkey_assertion'] ); + $is_valid = $this->validate_passwordless_assertion( $user, $assertion_json ); + + return ( true === $is_valid ); + } + + /** + * Render the user options for this provider. + * + * @since 0.17.0 + * + * @param WP_User $user WP_User object of the logged-in user. + */ + public function user_two_factor_options( $user ) { + $keys = get_user_meta( $user->ID, self::PASSKEYS_META_KEY, true ); + + if ( $keys ) { + /* translators: %s: number of passkeys */ + echo '' . sprintf( esc_html( _n( 'You have %s passkey registered.', 'You have %s passkeys registered.', count( $keys ), 'two-factor' ) ), count( $keys ) ) . '
'; + } else { + echo '' . esc_html__( 'No passkeys registered.', 'two-factor' ) . '
'; + } + + if ( get_current_user_id() === $user->ID ) { + ?> ++ +
+ provider = Two_Factor_Passkey::get_instance(); + } + + public function test_get_label() { + $this->assertSame( 'Passkeys', $this->provider->get_label() ); + } + + public function test_is_available_for_user() { + $user_id = $this->factory->user->create(); + $user = get_user_by( 'id', $user_id ); + + $this->assertFalse( $this->provider->is_available_for_user( $user ) ); + + update_user_meta( $user_id, Two_Factor_Passkey::PASSKEYS_META_KEY, array( 'dummy_credential_id' => 'data' ) ); + + $this->assertTrue( $this->provider->is_available_for_user( $user ) ); + } +} diff --git a/two-factor.php b/two-factor.php index b1db2039..6e44dbcb 100644 --- a/two-factor.php +++ b/two-factor.php @@ -41,6 +41,10 @@ */ require_once TWO_FACTOR_DIR . 'providers/class-two-factor-provider.php'; +if ( file_exists( TWO_FACTOR_DIR . 'vendor/autoload.php' ) ) { + require_once TWO_FACTOR_DIR . 'vendor/autoload.php'; +} + /** * Include the core that handles the common bits. */