Skip to content

Commit 6b44519

Browse files
committed
Enhancement: Add Passkeys (WebAuthn) support for Passwordless login & Passkey as 2FA (#779)
- Introduces Two_Factor_Passkey extending Two_Factor_Provider. - Hooks authenticate at priority 20 in Core to allow passwordless login. - Requires lbuchs/WebAuthn via Composer for PHP 7.2+ compatible CBOR/cryptography. - Implements frontend JS for navigator.credentials API. - Adds REST API endpoints for decoupled WebAuthn challenge generation. Fixes #779
1 parent c515462 commit 6b44519

7 files changed

Lines changed: 678 additions & 3 deletions

File tree

class-two-factor-core.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ public static function add_hooks( $compat ) {
125125
add_filter( 'wpmu_users_columns', array( __CLASS__, 'filter_manage_users_columns' ) );
126126
add_filter( 'manage_users_custom_column', array( __CLASS__, 'manage_users_custom_column' ), 10, 3 );
127127

128+
// 0. Intercept passkey authentications to bypass password requirement (priority 20).
129+
add_filter( 'authenticate', array( __CLASS__, 'filter_authenticate_passkey' ), 20, 3 );
130+
128131
// 1. Prevent WP core from sending login cookies after username/password authentication (priority 30).
129132
add_filter( 'authenticate', array( __CLASS__, 'filter_authenticate' ), 31 );
130133

@@ -261,6 +264,7 @@ private static function get_default_providers() {
261264
'Two_Factor_Email' => TWO_FACTOR_DIR . 'providers/class-two-factor-email.php',
262265
'Two_Factor_Totp' => TWO_FACTOR_DIR . 'providers/class-two-factor-totp.php',
263266
'Two_Factor_Backup_Codes' => TWO_FACTOR_DIR . 'providers/class-two-factor-backup-codes.php',
267+
'Two_Factor_Passkey' => TWO_FACTOR_DIR . 'providers/class-two-factor-passkey.php',
264268
'Two_Factor_Dummy' => TWO_FACTOR_DIR . 'providers/class-two-factor-dummy.php',
265269
);
266270
}
@@ -906,6 +910,57 @@ public static function destroy_current_session_for_user( $user ) {
906910
}
907911
}
908912

913+
/**
914+
* Intercept login to process Passwordless Passkey authentications.
915+
*
916+
* If a passkey payload is present, it validates it and returns the WP_User,
917+
* bypassing the standard password check.
918+
*
919+
* @since 0.17.0
920+
*
921+
* @param WP_User|WP_Error|null $user WP_User or WP_Error object from a previous callback. Default null.
922+
* @param string $username Username for authentication.
923+
* @param string $password Password for authentication.
924+
* @return WP_User|WP_Error|null WP_User on success, WP_Error on failure, or unchanged if not a passkey request.
925+
*/
926+
public static function filter_authenticate_passkey( $user, $username, $password ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
927+
// phpcs:ignore WordPress.Security.NonceVerification.Missing
928+
if ( empty( $_POST['two_factor_passkey_assertion'] ) || empty( $username ) ) {
929+
return $user;
930+
}
931+
932+
$passkey_provider = self::get_providers()['Two_Factor_Passkey'] ?? null;
933+
if ( ! $passkey_provider ) {
934+
return $user;
935+
}
936+
937+
// Find the user by username or email.
938+
$user_obj = get_user_by( 'login', $username );
939+
if ( ! $user_obj ) {
940+
$user_obj = get_user_by( 'email', $username );
941+
}
942+
943+
if ( ! $user_obj || ! $passkey_provider->is_available_for_user( $user_obj ) ) {
944+
return new WP_Error( 'invalid_passkey', __( 'No passkeys found for this user.', 'two-factor' ) );
945+
}
946+
947+
// Delegate validation to the provider.
948+
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
949+
$assertion_json = wp_unslash( $_POST['two_factor_passkey_assertion'] );
950+
$is_valid = $passkey_provider->validate_passwordless_assertion( $user_obj, $assertion_json );
951+
952+
if ( is_wp_error( $is_valid ) ) {
953+
return $is_valid;
954+
} elseif ( $is_valid ) {
955+
// Passkeys satisfy both factors. Bypass the Two-Factor UI enforcement.
956+
remove_filter( 'authenticate', array( __CLASS__, 'filter_authenticate' ), 31 );
957+
remove_action( 'wp_login', array( __CLASS__, 'wp_login' ), PHP_INT_MAX );
958+
return $user_obj;
959+
}
960+
961+
return new WP_Error( 'invalid_passkey', __( 'Invalid Passkey authentication.', 'two-factor' ) );
962+
}
963+
909964
/**
910965
* Disable WP core login cookies for users that require second factor. Disable
911966
* authenticated API requests unless explicitly enabled for the user (disabled by default).

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
"minimum-stability": "dev",
2222
"prefer-stable" : true,
2323
"require": {
24-
"php": ">=7.2.24|^8"
24+
"php": ">=7.2.24|^8",
25+
"lbuchs/webauthn": "^2.0"
2526
},
2627
"require-dev": {
2728
"automattic/vipwpcs": "^3.0",

composer.lock

Lines changed: 48 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

js/passkeys.js

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
// Passkeys frontend implementation
2+
document.addEventListener('DOMContentLoaded', function() {
3+
4+
// Registration Flow
5+
const registerBtn = document.getElementById('two-factor-passkey-register-btn');
6+
if (registerBtn) {
7+
registerBtn.addEventListener('click', async function() {
8+
try {
9+
registerBtn.disabled = true;
10+
registerBtn.textContent = 'Registering...';
11+
12+
// 1. Get creation options from server
13+
const optionsRes = await fetch(twoFactorPasskeyData.restUrl + 'passkeys/options?action=register', {
14+
method: 'GET',
15+
headers: { 'X-WP-Nonce': twoFactorPasskeyData.nonce }
16+
});
17+
const options = await optionsRes.json();
18+
if (!optionsRes.ok) throw new Error(options.message || 'Failed to get options');
19+
20+
// 2. Decode options (Base64Url to Uint8Array)
21+
const createArgs = recursiveBase64StrToArrayBuffer(options);
22+
23+
// 3. Prompt user to create passkey
24+
const credential = await navigator.credentials.create(createArgs);
25+
26+
// 4. Encode response
27+
const data = {
28+
id: credential.id,
29+
rawId: arrayBufferToBase64(credential.rawId),
30+
type: credential.type,
31+
response: {
32+
attestationObject: arrayBufferToBase64(credential.response.attestationObject),
33+
clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON)
34+
}
35+
};
36+
37+
// 5. Send to server to register
38+
const regRes = await fetch(twoFactorPasskeyData.restUrl + 'passkeys/register', {
39+
method: 'POST',
40+
headers: {
41+
'Content-Type': 'application/json',
42+
'X-WP-Nonce': twoFactorPasskeyData.nonce
43+
},
44+
body: JSON.stringify(data)
45+
});
46+
47+
const regData = await regRes.json();
48+
if (!regRes.ok) throw new Error(regData.message || 'Registration failed');
49+
50+
alert('Passkey registered successfully!');
51+
window.location.reload();
52+
53+
} catch (err) {
54+
console.error(err);
55+
alert('Passkey registration failed: ' + err.message);
56+
} finally {
57+
registerBtn.disabled = false;
58+
registerBtn.textContent = 'Register New Passkey';
59+
}
60+
});
61+
}
62+
63+
// Login Flow (Option 1 - Passwordless)
64+
// For passwordless, we need to intercept the standard login form.
65+
const loginForm = document.getElementById('loginform');
66+
if (loginForm && !document.getElementById('twofactorform')) {
67+
const userLoginInput = document.getElementById('user_login');
68+
69+
// Add a "Login with Passkey" button
70+
const passkeyLoginBtn = document.createElement('button');
71+
passkeyLoginBtn.type = 'button';
72+
passkeyLoginBtn.className = 'button button-secondary button-large';
73+
passkeyLoginBtn.style.marginTop = '10px';
74+
passkeyLoginBtn.style.width = '100%';
75+
passkeyLoginBtn.textContent = 'Login with Passkey';
76+
77+
// Insert after the submit button
78+
const submitP = loginForm.querySelector('.submit');
79+
if (submitP) {
80+
submitP.appendChild(passkeyLoginBtn);
81+
}
82+
83+
passkeyLoginBtn.addEventListener('click', async function(e) {
84+
e.preventDefault();
85+
const username = userLoginInput.value.trim();
86+
if (!username) {
87+
alert('Please enter your username or email address first.');
88+
userLoginInput.focus();
89+
return;
90+
}
91+
92+
try {
93+
passkeyLoginBtn.disabled = true;
94+
passkeyLoginBtn.textContent = 'Authenticating...';
95+
96+
const optionsRes = await fetch(twoFactorPasskeyData.restUrl + 'passkeys/options?action=authenticate&username=' + encodeURIComponent(username));
97+
const options = await optionsRes.json();
98+
if (!optionsRes.ok) throw new Error(options.message || 'Failed to get options');
99+
100+
const sessionId = options.session_id;
101+
const getArgs = recursiveBase64StrToArrayBuffer(options.args);
102+
const credential = await navigator.credentials.get(getArgs);
103+
104+
const assertion = {
105+
id: credential.id,
106+
rawId: arrayBufferToBase64(credential.rawId),
107+
type: credential.type,
108+
session_id: sessionId,
109+
response: {
110+
authenticatorData: arrayBufferToBase64(credential.response.authenticatorData),
111+
clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON),
112+
signature: arrayBufferToBase64(credential.response.signature),
113+
userHandle: credential.response.userHandle ? arrayBufferToBase64(credential.response.userHandle) : null
114+
}
115+
};
116+
117+
let assertionInput = document.getElementById('two_factor_passkey_assertion');
118+
if (!assertionInput) {
119+
assertionInput = document.createElement('input');
120+
assertionInput.type = 'hidden';
121+
assertionInput.id = 'two_factor_passkey_assertion';
122+
assertionInput.name = 'two_factor_passkey_assertion';
123+
loginForm.appendChild(assertionInput);
124+
}
125+
assertionInput.value = JSON.stringify(assertion);
126+
127+
HTMLFormElement.prototype.submit.call(loginForm);
128+
129+
} catch (err) {
130+
console.error(err);
131+
alert('Passkey login failed: ' + err.message);
132+
passkeyLoginBtn.disabled = false;
133+
passkeyLoginBtn.textContent = 'Login with Passkey';
134+
}
135+
});
136+
}
137+
138+
// Login Flow (Option 2 - 2FA Interstitial)
139+
const authBtn2FA = document.getElementById('two-factor-passkey-auth-btn');
140+
if (authBtn2FA) {
141+
const twoFactorForm = authBtn2FA.closest('form');
142+
authBtn2FA.addEventListener('click', async function(e) {
143+
e.preventDefault();
144+
const username = authBtn2FA.getAttribute('data-username');
145+
if (!username) return;
146+
147+
try {
148+
authBtn2FA.disabled = true;
149+
authBtn2FA.textContent = 'Authenticating...';
150+
151+
const optionsRes = await fetch(twoFactorPasskeyData.restUrl + 'passkeys/options?action=authenticate&username=' + encodeURIComponent(username));
152+
const options = await optionsRes.json();
153+
if (!optionsRes.ok) throw new Error(options.message || 'Failed to get options');
154+
155+
const sessionId = options.session_id;
156+
const getArgs = recursiveBase64StrToArrayBuffer(options.args);
157+
const credential = await navigator.credentials.get(getArgs);
158+
159+
const assertion = {
160+
id: credential.id,
161+
rawId: arrayBufferToBase64(credential.rawId),
162+
type: credential.type,
163+
session_id: sessionId,
164+
response: {
165+
authenticatorData: arrayBufferToBase64(credential.response.authenticatorData),
166+
clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON),
167+
signature: arrayBufferToBase64(credential.response.signature),
168+
userHandle: credential.response.userHandle ? arrayBufferToBase64(credential.response.userHandle) : null
169+
}
170+
};
171+
172+
let assertionInput = document.getElementById('two_factor_passkey_assertion');
173+
if (!assertionInput) {
174+
assertionInput = document.createElement('input');
175+
assertionInput.type = 'hidden';
176+
assertionInput.id = 'two_factor_passkey_assertion';
177+
assertionInput.name = 'two_factor_passkey_assertion';
178+
twoFactorForm.appendChild(assertionInput);
179+
}
180+
assertionInput.value = JSON.stringify(assertion);
181+
182+
HTMLFormElement.prototype.submit.call(twoFactorForm);
183+
184+
} catch (err) {
185+
console.error(err);
186+
alert('Passkey authentication failed: ' + err.message);
187+
authBtn2FA.disabled = false;
188+
authBtn2FA.textContent = 'Use Passkey';
189+
}
190+
});
191+
}
192+
193+
// Helpers for Base64Url
194+
function arrayBufferToBase64(buffer) {
195+
let binary = '';
196+
const bytes = new Uint8Array(buffer);
197+
const len = bytes.byteLength;
198+
for (let i = 0; i < len; i++) {
199+
binary += String.fromCharCode(bytes[i]);
200+
}
201+
return window.btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
202+
}
203+
204+
function base64ToArrayBuffer(base64) {
205+
base64 = base64.replace(/-/g, "+").replace(/_/g, "/");
206+
const padLen = (4 - (base64.length % 4)) % 4;
207+
base64 += "=".repeat(padLen);
208+
const binary_string = window.atob(base64);
209+
const len = binary_string.length;
210+
const bytes = new Uint8Array(len);
211+
for (let i = 0; i < len; i++) {
212+
bytes[i] = binary_string.charCodeAt(i);
213+
}
214+
return bytes.buffer;
215+
}
216+
217+
function recursiveBase64StrToArrayBuffer(obj) {
218+
let prefix = '=?BINARY?B?';
219+
let suffix = '?=';
220+
if (typeof obj === 'object' && obj !== null) {
221+
for (let key in obj) {
222+
if (typeof obj[key] === 'string') {
223+
let str = obj[key];
224+
if (str.substring(0, prefix.length) === prefix && str.substring(str.length - suffix.length) === suffix) {
225+
str = str.substring(prefix.length, str.length - suffix.length);
226+
obj[key] = base64ToArrayBuffer(str);
227+
}
228+
} else {
229+
recursiveBase64StrToArrayBuffer(obj[key]);
230+
}
231+
}
232+
}
233+
return obj;
234+
}
235+
});

0 commit comments

Comments
 (0)