Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions class-two-factor-core.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );

Expand Down Expand Up @@ -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',
);
}
Expand Down Expand Up @@ -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).
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
50 changes: 48 additions & 2 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

235 changes: 235 additions & 0 deletions js/passkeys.js
Original file line number Diff line number Diff line change
@@ -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;
}
});
Loading
Loading