Skip to content

Commit

Permalink
Refactored WebAuthn with Windows Hello support (#37910)
Browse files Browse the repository at this point in the history
* Refactored WebAuthn plugin

* Fix the WebAuthn management page which was broken in #37464

* Fix wrong `@since` doc tag

* Fix docblock typo

* Fix docblock typo

* Fix docblock typo

* Fix docblock typo

* Fix docblock typo

* Fix broken management interface

* Make unnecessarily static method back into non-static

* Replace static helper with injected object

* Come on, commit the ENTIRE file!

* Use the user factory

* Fix error when going through the user factory

* Fix: cannot add WebAuthn authenticator right back after deleting it

* Remove useless switch branch

* Remove useless exception

* Display make and model of the authenticator, if possible

* Add missing JWT signature algorithms

* Fix copyright date

* Fix for PHP 8 using FIDO keys and Android phones

* Reactivate the tooltips after adding an authenticator

* Option to disable the attestation support

* The Windows Hello icon was invisible on white background

* Attempt to fix Appveyor not having Sodium in the Windows build

* Work around third party library bug...

* Create events in a forwards-compatible manner

* Concrete events

* Fix event woes

* Update plugins/system/webauthn/webauthn.xml

Co-authored-by: Brian Teeman <brian@teeman.net>

* Update administrator/language/en-GB/plg_system_webauthn.ini

Co-authored-by: Brian Teeman <brian@teeman.net>

* Improve the layout for editing an authenticator

It now follows the Bootstrap 5 form aesthetic. Moreover,
there are gaps between the text input and the Save and
Cancel buttons.

* Confirm deletion of authenticators

* Make the bots happy again

* Code polishing

* Marking classes final
* Use setApplication / getApplication in the plugin class
* Remove unused `$db` from the plugin class

* Blind fix

Currently #38060 has broken everything it seems?

* Bring application injection in sync with core

* Remove whitespace

* Add use statement

* Fix wrong event creation in AjaxHandlerLogin

* License change

Co-authored-by: Richard Fath <richard67@users.noreply.github.com>
Co-authored-by: Brian Teeman <brian@teeman.net>
Co-authored-by: Roland Dalmulder <contact@rolandd.com>
Co-authored-by: Allon Moritz <allon.moritz@digital-peak.com>
Co-authored-by: Harald Leithner <leithner@itronic.at>
Co-authored-by: George Wilson <georgejameswilson@googlemail.com>
  • Loading branch information
7 people authored Jun 27, 2022
1 parent 8b2cf5a commit 170f91a
Show file tree
Hide file tree
Showing 41 changed files with 4,090 additions and 1,794 deletions.
2 changes: 1 addition & 1 deletion .appveyor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ install:
- choco install composer
- cd C:\projects\joomla-cms
- refreshenv
- composer install --no-progress --profile
- composer install --no-progress --profile --ignore-platform-req=ext-sodium
before_test:
# Database setup for MySQL via PowerShell tools
- >
Expand Down
8 changes: 6 additions & 2 deletions administrator/language/en-GB/plg_system_webauthn.ini
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,21 @@ PLG_SYSTEM_WEBAUTHN_ERR_CREDENTIAL_ID_ALREADY_IN_USE="Cannot save credentials. T
PLG_SYSTEM_WEBAUTHN_ERR_EMPTY_USERNAME="You need to enter your username (but NOT your password) before selecting the Web Authentication login button."
PLG_SYSTEM_WEBAUTHN_ERR_INVALID_USERNAME="The specified username does not correspond to a user account that has enabled passwordless login on this site."
PLG_SYSTEM_WEBAUTHN_ERR_LABEL_NOT_SAVED="Could not save the new label"
PLG_SYSTEM_WEBAUTHN_ERR_NOT_DELETED="Could not remove the authenticator"
PLG_SYSTEM_WEBAUTHN_ERR_NO_BROWSER_SUPPORT="Sorry, your browser does not support the W3C Web Authentication standard for passwordless logins or your site is not being served over HTTPS with a valid certificate, signed by a Certificate Authority your browser trusts. You will need to log into this site using your username and password."
PLG_SYSTEM_WEBAUTHN_ERR_NO_STORED_CREDENTIAL="Cannot find the stored credentials for your login authenticator."
PLG_SYSTEM_WEBAUTHN_ERR_NOT_DELETED="Could not remove the authenticator"
PLG_SYSTEM_WEBAUTHN_ERR_USER_REMOVED="The user for this authenticator seems to no longer exist on this site."
PLG_SYSTEM_WEBAUTHN_ERR_XHR_INITCREATE="Cannot get the authenticator registration information from your site."
PLG_SYSTEM_WEBAUTHN_FIELD_ATTESTATION_SUPPORT_DESC="Only allow authenticators with verifiable cryptographic signatures to be used for WebAuthn logins. Strongly recommended for high security environments. Requires your site to be able to access <code>https://mds.fidoalliance.org/</code> directly, a writeable cache directory, the system temporary directory being writeable by PHP, and the OpenSSL extension. May prevent some cheaper, non-certified authenticators from working at all. Disabling it also prevents Joomla from identifying the make and model of the authenticator you are using (no icon will be displayed next to the Authenticator Name).<br/><strong>Pro tip:</strong> If you are behind a firewall you can place the data downloaded from <a href='https://mds.fidoalliance.org/' target='_blank'>the FIDO Alliance</a> into the file <code>administrator/cache/fido.jwt</code> for this feature to work properly."
PLG_SYSTEM_WEBAUTHN_FIELD_ATTESTATION_SUPPORT_LABEL="Attestation Support"
PLG_SYSTEM_WEBAUTHN_FIELD_DESC="Lets you manage passwordless login methods using the W3C Web Authentication standard. You need a supported browser and authenticator (eg Google Chrome or Firefox with a FIDO2 certified security key).<br><br><strong>MacOS/iOS/watchOS:</strong> Touch/Face ID.<br><strong>Windows:</strong> Hello (Fingerprint / Facial Recognition / PIN).<br><strong>Android:</strong> Biometric screen lock.<br><br>You can find more details in the <a href=\"https://docs.joomla.org/Special:MyLanguage/WebAuthn_Passwordless_Login\" target=\"_blank\" rel=\"noopener noreferrer\">WebAuthn Passwordless Login documentation</a>."
PLG_SYSTEM_WEBAUTHN_FIELD_LABEL="W3C Web Authentication (WebAuthn) Login"
PLG_SYSTEM_WEBAUTHN_FIELD_N_AUTHENTICATORS_REGISTERED="%d WebAuthn authenticators already set up: %s"
PLG_SYSTEM_WEBAUTHN_FIELD_N_AUTHENTICATORS_REGISTERED_0="No WebAuthn authenticator has been set up yet"
PLG_SYSTEM_WEBAUTHN_FIELD_N_AUTHENTICATORS_REGISTERED_1="One WebAuthn authenticator already set up: %2$s"
PLG_SYSTEM_WEBAUTHN_HEADER="W3C Web Authentication (WebAuthn) Login"
PLG_SYSTEM_WEBAUTHN_LBL_DEFAULT_AUTHENTICATOR_LABEL="Authenticator added on %s"
PLG_SYSTEM_WEBAUTHN_LBL_DEFAULT_AUTHENTICATOR="Generic Authenticator"
PLG_SYSTEM_WEBAUTHN_LBL_DEFAULT_AUTHENTICATOR_LABEL="%s added on %s"
PLG_SYSTEM_WEBAUTHN_LOGIN_DESC="Login without a password using the W3C Web Authentication (WebAuthn) standard in compatible browsers. You need to have already set up WebAuthn authentication in your user profile."
PLG_SYSTEM_WEBAUTHN_LOGIN_LABEL="Web Authentication"
PLG_SYSTEM_WEBAUTHN_MANAGE_BTN_ADD_LABEL="Add New Authenticator"
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 12 additions & 10 deletions build/media_source/plg_system_webauthn/js/login.es6.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,8 @@ window.Joomla = window.Joomla || {};
* internal page which handles the login server-side.
*
* @param { Object} publicKey Public key request options, returned from the server
* @param {String} callbackUrl The URL we will use to post back to the server. Must include
* the anti-CSRF token.
*/
const handleLoginChallenge = (publicKey, callbackUrl) => {
const handleLoginChallenge = (publicKey) => {
const arrayToBase64String = (a) => btoa(String.fromCharCode(...a));

const base64url2base64 = (input) => {
Expand Down Expand Up @@ -172,7 +170,8 @@ window.Joomla = window.Joomla || {};
};

// Send the response to your server
window.location = `${callbackUrl}&option=com_ajax&group=system&plugin=webauthn&`
const paths = Joomla.getOptions('system.paths');
window.location = `${paths ? `${paths.base}/index.php` : window.location.pathname}?${Joomla.getOptions('csrf.token')}=1&option=com_ajax&group=system&plugin=webauthn&`
+ `format=raw&akaction=login&encoding=redirect&data=${
btoa(JSON.stringify(publicKeyCredential))}`;
})
Expand All @@ -187,13 +186,11 @@ window.Joomla = window.Joomla || {};
* for the user.
*
* @param {string} formId The login form's or login module's HTML ID
* @param {string} callbackUrl The URL we will use to post back to the server. Must include
* the anti-CSRF token.
*
* @returns {boolean} Always FALSE to prevent BUTTON elements from reloading the page.
*/
// eslint-disable-next-line no-unused-vars
Joomla.plgSystemWebauthnLogin = (formId, callbackUrl) => {
Joomla.plgSystemWebauthnLogin = (formId) => {
// Get the username
const elFormContainer = document.getElementById(formId);
const elUsername = lookForField(elFormContainer, 'input[name=username]');
Expand Down Expand Up @@ -226,9 +223,14 @@ window.Joomla = window.Joomla || {};
username,
returnUrl,
};
postBackData[Joomla.getOptions('csrf.token')] = 1;

const paths = Joomla.getOptions('system.paths');

Joomla.request({
url: callbackUrl,
url: `${paths ? `${paths.base}/index.php` : window.location.pathname}?${Joomla.getOptions(
'csrf.token',
)}=1`,
method: 'POST',
data: interpolateParameters(postBackData),
onSuccess(rawResponse) {
Expand All @@ -243,7 +245,7 @@ window.Joomla = window.Joomla || {};
*/
}

handleLoginChallenge(jsonData, callbackUrl);
handleLoginChallenge(jsonData);
},
onError: (xhr) => {
handleLoginError(`${xhr.status} ${xhr.statusText}`);
Expand All @@ -258,7 +260,7 @@ window.Joomla = window.Joomla || {};
if (loginButtons.length) {
loginButtons.forEach((button) => {
button.addEventListener('click', ({ currentTarget }) => {
Joomla.plgSystemWebauthnLogin(currentTarget.getAttribute('data-webauthn-form'), currentTarget.getAttribute('data-webauthn-url'));
Joomla.plgSystemWebauthnLogin(currentTarget.getAttribute('data-webauthn-form'));
});
});
}
Expand Down
133 changes: 97 additions & 36 deletions build/media_source/plg_system_webauthn/js/management.es6.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,29 +62,52 @@ window.Joomla = window.Joomla || {};
* Posts the credentials to the URL defined in post_url using AJAX.
* That URL must re-render the management interface.
* These contents will replace the element identified by the interface_selector CSS selector.
*
* @param {String} storeID CSS ID for the element storing the configuration in its
* data properties
* @param {String} interfaceSelector CSS selector for the GUI container
*/
// eslint-disable-next-line no-unused-vars
Joomla.plgSystemWebauthnCreateCredentials = (storeID, interfaceSelector) => {
Joomla.plgSystemWebauthnInitCreateCredentials = () => {
// Make sure the browser supports Webauthn
if (!('credentials' in navigator)) {
Joomla.renderMessages({ error: [Joomla.Text._('PLG_SYSTEM_WEBAUTHN_ERR_NO_BROWSER_SUPPORT')] });

return;
}

// Extract the configuration from the store
const elStore = document.getElementById(storeID);
// Get the public key creation options through AJAX.
const paths = Joomla.getOptions('system.paths');
const postURL = `${paths ? `${paths.base}/index.php` : window.location.pathname}`;

if (!elStore) {
return;
}
const postBackData = {
option: 'com_ajax',
group: 'system',
plugin: 'webauthn',
format: 'json',
akaction: 'initcreate',
encoding: 'json',
};
postBackData[Joomla.getOptions('csrf.token')] = 1;

Joomla.request({
url: postURL,
method: 'POST',
data: interpolateParameters(postBackData),
onSuccess(response) {
try {
const publicKey = JSON.parse(response);

Joomla.plgSystemWebauthnCreateCredentials(publicKey);
} catch (exception) {
handleCreationError(Joomla.Text._('PLG_SYSTEM_WEBAUTHN_ERR_XHR_INITCREATE'));
}
},
onError: (xhr) => {
handleCreationError(`${xhr.status} ${xhr.statusText}`);
},
});
};

const publicKey = JSON.parse(atob(elStore.dataset.public_key));
const postURL = atob(elStore.dataset.postback_url);
Joomla.plgSystemWebauthnCreateCredentials = (publicKey) => {
const paths = Joomla.getOptions('system.paths');
const postURL = `${paths ? `${paths.base}/index.php` : window.location.pathname}`;

const arrayToBase64String = (a) => btoa(String.fromCharCode(...a));

Expand Down Expand Up @@ -137,13 +160,14 @@ window.Joomla = window.Joomla || {};
encoding: 'raw',
data: btoa(JSON.stringify(publicKeyCredential)),
};
postBackData[Joomla.getOptions('csrf.token')] = 1;

Joomla.request({
url: postURL,
method: 'POST',
data: interpolateParameters(postBackData),
onSuccess(responseHTML) {
const elements = document.querySelectorAll(interfaceSelector);
const elements = document.querySelectorAll('#plg_system_webauthn-management-interface');

if (!elements) {
return;
Expand All @@ -154,6 +178,7 @@ window.Joomla = window.Joomla || {};
elContainer.outerHTML = responseHTML;

Joomla.plgSystemWebauthnInitialize();
Joomla.plgSystemWebauthnReactivateTooltips();
},
onError: (xhr) => {
handleCreationError(`${xhr.status} ${xhr.statusText}`);
Expand All @@ -175,15 +200,9 @@ window.Joomla = window.Joomla || {};
* properties
*/
// eslint-disable-next-line no-unused-vars
Joomla.plgSystemWebauthnEditLabel = (that, storeID) => {
// Extract the configuration from the store
const elStore = document.getElementById(storeID);

if (!elStore) {
return false;
}

const postURL = atob(elStore.dataset.postback_url);
Joomla.plgSystemWebauthnEditLabel = (that) => {
const paths = Joomla.getOptions('system.paths');
const postURL = `${paths ? `${paths.base}/index.php` : window.location.pathname}`;

// Find the UI elements
const elTR = that.parentElement.parentElement;
Expand All @@ -198,10 +217,14 @@ window.Joomla = window.Joomla || {};
// Show the editor
const oldLabel = elLabelTD.innerText;

const elContainer = document.createElement('div');
elContainer.className = 'webauthnManagementEditorRow d-flex gap-2';

const elInput = document.createElement('input');
elInput.type = 'text';
elInput.name = 'label';
elInput.defaultValue = oldLabel;
elInput.className = 'form-control';

const elSave = document.createElement('button');
elSave.className = 'btn btn-success btn-sm';
Expand All @@ -220,6 +243,7 @@ window.Joomla = window.Joomla || {};
credential_id: credentialId,
new_label: elNewLabel,
};
postBackData[Joomla.getOptions('csrf.token')] = 1;

Joomla.request({
url: postURL,
Expand Down Expand Up @@ -268,9 +292,10 @@ window.Joomla = window.Joomla || {};
}, false);

elLabelTD.innerHTML = '';
elLabelTD.appendChild(elInput);
elLabelTD.appendChild(elSave);
elLabelTD.appendChild(elCancel);
elContainer.appendChild(elInput);
elContainer.appendChild(elSave);
elContainer.appendChild(elCancel);
elLabelTD.appendChild(elContainer);
elEdit.disabled = true;
elDelete.disabled = true;

Expand All @@ -281,19 +306,15 @@ window.Joomla = window.Joomla || {};
* Delete button
*
* @param {Element} that The button being clicked
* @param {String} storeID CSS ID for the element storing the configuration in its data
* properties
*/
// eslint-disable-next-line no-unused-vars
Joomla.plgSystemWebauthnDelete = (that, storeID) => {
// Extract the configuration from the store
const elStore = document.getElementById(storeID);

if (!elStore) {
Joomla.plgSystemWebauthnDelete = (that) => {
if (!window.confirm(Joomla.Text._('JGLOBAL_CONFIRM_DELETE'))) {
return false;
}

const postURL = atob(elStore.dataset.postback_url);
const paths = Joomla.getOptions('system.paths');
const postURL = `${paths ? `${paths.base}/index.php` : window.location.pathname}`;

// Find the UI elements
const elTR = that.parentElement.parentElement;
Expand All @@ -317,6 +338,7 @@ window.Joomla = window.Joomla || {};
akaction: 'delete',
credential_id: credentialId,
};
postBackData[Joomla.getOptions('csrf.token')] = 1;

Joomla.request({
url: postURL,
Expand Down Expand Up @@ -354,6 +376,45 @@ window.Joomla = window.Joomla || {};
return false;
};

Joomla.plgSystemWebauthnReactivateTooltips = () => {
const tooltips = Joomla.getOptions('bootstrap.tooltip');
if (typeof tooltips === 'object' && tooltips !== null) {
Object.keys(tooltips).forEach((tooltip) => {
const opt = tooltips[tooltip];
const options = {
animation: opt.animation ? opt.animation : true,
container: opt.container ? opt.container : false,
delay: opt.delay ? opt.delay : 0,
html: opt.html ? opt.html : false,
selector: opt.selector ? opt.selector : false,
trigger: opt.trigger ? opt.trigger : 'hover focus',
fallbackPlacement: opt.fallbackPlacement ? opt.fallbackPlacement : null,
boundary: opt.boundary ? opt.boundary : 'clippingParents',
title: opt.title ? opt.title : '',
customClass: opt.customClass ? opt.customClass : '',
sanitize: opt.sanitize ? opt.sanitize : true,
sanitizeFn: opt.sanitizeFn ? opt.sanitizeFn : null,
popperConfig: opt.popperConfig ? opt.popperConfig : null,
};

if (opt.placement) {
options.placement = opt.placement;
}
if (opt.template) {
options.template = opt.template;
}
if (opt.allowList) {
options.allowList = opt.allowList;
}

const elements = Array.from(document.querySelectorAll(tooltip));
if (elements.length) {
elements.map((el) => new window.bootstrap.Tooltip(el, options));
}
});
}
};

/**
* Add New Authenticator button click handler
*
Expand All @@ -364,7 +425,7 @@ window.Joomla = window.Joomla || {};
Joomla.plgSystemWebauthnAddOnClick = (event) => {
event.preventDefault();

Joomla.plgSystemWebauthnCreateCredentials(event.currentTarget.getAttribute('data-random-id'), '#plg_system_webauthn-management-interface');
Joomla.plgSystemWebauthnInitCreateCredentials();

return false;
};
Expand All @@ -379,7 +440,7 @@ window.Joomla = window.Joomla || {};
Joomla.plgSystemWebauthnEditOnClick = (event) => {
event.preventDefault();

Joomla.plgSystemWebauthnEditLabel(event.currentTarget, event.currentTarget.getAttribute('data-random-id'));
Joomla.plgSystemWebauthnEditLabel(event.currentTarget);

return false;
};
Expand All @@ -394,7 +455,7 @@ window.Joomla = window.Joomla || {};
Joomla.plgSystemWebauthnDeleteOnClick = (event) => {
event.preventDefault();

Joomla.plgSystemWebauthnDelete(event.currentTarget, event.currentTarget.getAttribute('data-random-id'));
Joomla.plgSystemWebauthnDelete(event.currentTarget);

return false;
};
Expand Down
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@
"web-auth/webauthn-lib": "2.1.*",
"composer/ca-bundle": "^1.2",
"dragonmantank/cron-expression": "^3.1",
"enshrined/svg-sanitize": "^0.15.4"
"enshrined/svg-sanitize": "^0.15.4",
"lcobucci/jwt": "^3.4.6",
"web-token/signature-pack": "^2.2.11"
},
"require-dev": {
"phpunit/phpunit": "^8.5",
Expand Down
Loading

0 comments on commit 170f91a

Please sign in to comment.