29
0
mirror of https://github.com/joomla/joomla-cms.git synced 2024-06-24 22:39:31 +00:00

[4.0] WebAuthn Passwordless Authentication – Reloaded (#28094)

This PR adds support for WebAuthn (W3C Web Authentication). This is a W3C standard made official in March 2019.

WebAuthn allows users to authenticate (log in) securely into a site without using a password. Instead it uses authenticators. An authenticator is either a discrete hardware device or a Trusted Platform Module (TPM) / Secure Enclave built into your device. Moreover, it only works under HTTPS. By using secure hardware and secure transport it makes sure that the authentication is resistant to eavesdropping, phishing, brute-force and other attack modes associated with fixed passwords.
This commit is contained in:
Harald Leithner 2020-03-05 14:24:08 +01:00 committed by GitHub
parent 8ff9a7ee2e
commit 73627c5aa6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 4867 additions and 9 deletions

7
.github/CODEOWNERS vendored
View File

@ -47,3 +47,10 @@ RoboFile.php @rdeutz @hackwar
plugins/system/httpheaders/* @zero-24
administrator/components/com_csp/* @zero-24
components/com_csp/* @zero-24
# Web Authentication (WebAuthn)
plugins/system/webauthn/* @nikosdion
media/plg_system_webauthn/* @nikosdion
language/administrator/en-GB/en-GB.plg_system_webauthn.ini @nikosdion
language/administrator/en-GB/en-GB.plg_system_webauthn.sys.ini @nikosdion

View File

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS `#__webauthn_credentials`
(
`id` VARCHAR(1000) NOT NULL COMMENT 'Credential ID',
`user_id` VARCHAR(128) NOT NULL COMMENT 'User handle',
`label` VARCHAR(190) NOT NULL COMMENT 'Human readable label',
`credential` MEDIUMTEXT NOT NULL COMMENT 'Credential source data, JSON format',
PRIMARY KEY (`id`(100)),
INDEX (`user_id`(60))
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
DEFAULT COLLATE = utf8mb4_unicode_ci;
INSERT INTO `#__extensions` (`package_id`, `name`, `type`, `element`, `folder`, `client_id`, `enabled`, `access`, `protected`, `manifest_cache`, `params`, `checked_out`, `checked_out_time`, `ordering`, `state`) VALUES
(0, 'plg_system_webauthn', 'plugin', 'webauthn', 'system', 0, 1, 1, 0, '', '{}', 0, NULL, 0, 0);

View File

@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS "#__webauthn_credentials"
(
"id" varchar(1000) NOT NULL,
"user_id" varchar(128) NOT NULL,
"label" varchar(190) NOT NULL,
"credential" TEXT NOT NULL,
PRIMARY KEY ("id")
);
CREATE INDEX "#__webauthn_credentials_user_id" ON "#__webauthn_credentials" ("user_id");
INSERT INTO "#__extensions" ("package_id", "name", "type", "element", "folder", "client_id", "enabled", "access", "protected", "manifest_cache", "params", "checked_out", "checked_out_time", "ordering", "state") VALUES
(0, 'plg_system_webauthn', 'plugin', 'webauthn', 'system', 0, 1, 1, 0, '', '{}', 0, NULL, 8, 0);

View File

@ -0,0 +1,46 @@
; Joomla! Project
; Copyright (C) 2005 - 2019 Open Source Matters. All rights reserved.
; License GNU General Public License version 2 or later; see LICENSE.txt, see LICENSE.php
; Note : All ini files need to be saved as UTF-8
PLG_SYSTEM_WEBAUTHN="System - WebAuthn Passwordless Login"
PLG_SYSTEM_WEBAUTHN_DESCRIPTION="Enables passwordless authentication using the W3C Web Authentication (WebAuthn) API."
PLG_SYSTEM_WEBAUTHN_LOGIN_LABEL="Web Authentication"
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_HEADER="W3C Web Authentication (WebAuthn) Login"
PLG_SYSTEM_WEBAUTHN_FIELD_LABEL="W3C Web Authentication (WebAuthn) Login"
PLG_SYSTEM_WEBAUTHN_FIELD_DESC="Lets you manage passwordless login methods using the W3C Web Authentication standard. You need a supported browser and authenticator (e.g. Google Chrome or Firefox with a FIDO2 certified security key)."
PLG_SYSTEM_WEBAUTHN_MANAGE_FIELD_KEYLABEL_LABEL="Authenticator Name"
PLG_SYSTEM_WEBAUTHN_MANAGE_FIELD_KEYLABEL_DESC="A short name for the authenticator used with this passwordless login method."
PLG_SYSTEM_WEBAUTHN_MANAGE_HEADER_NOMETHODS_LABEL="No authenticators have been set up yet."
PLG_SYSTEM_WEBAUTHN_MANAGE_HEADER_ACTIONS_LABEL="Actions"
PLG_SYSTEM_WEBAUTHN_MANAGE_BTN_DELETE_LABEL="Remove"
PLG_SYSTEM_WEBAUTHN_MANAGE_BTN_EDIT_LABEL="Edit Name"
PLG_SYSTEM_WEBAUTHN_MANAGE_BTN_ADD_LABEL="Add New Authenticator"
PLG_SYSTEM_WEBAUTHN_MANAGE_BTN_SAVE_LABEL="Save"
PLG_SYSTEM_WEBAUTHN_MANAGE_BTN_CANCEL_LABEL="Cancel"
PLG_SYSTEM_WEBAUTHN_LBL_DEFAULT_AUTHENTICATOR_LABEL="Authenticator added on %s"
PLG_SYSTEM_WEBAUTHN_MSG_SAVED_LABEL="The label has been saved."
PLG_SYSTEM_WEBAUTHN_MSG_DELETED="The authenticator has been removed."
PLG_SYSTEM_WEBAUTHN_ERR_NO_STORED_CREDENTIAL="Cannot find the stored credentials for your login authenticator."
PLG_SYSTEM_WEBAUTHN_ERR_CORRUPT_STORED_CREDENTIAL="The stored credentials are corrupt for your user account. Log in using another method, then remove and add again your login authenticator."
PLG_SYSTEM_WEBAUTHN_ERR_CANT_STORE_FOR_GUEST="Cannot possibly store credentials for Guest user!"
PLG_SYSTEM_WEBAUTHN_ERR_CREDENTIAL_ID_ALREADY_IN_USE="Cannot save credentials. These credentials are already being used by a different user."
PLG_SYSTEM_WEBAUTHN_ERR_USER_REMOVED="The user for this authenticator seems to no longer exist on this site."
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_CREATE_NO_PK="The server has not issued a Public Key for authenticator registration but somehow received an authenticator registration request from the browser. This means that someone tried to hack you or something is broken."
PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_PK="The authenticator registration has failed. The authenticator response received from the browser does not match the Public Key issued by the server. This means that someone tried to hack you or something is broken."
PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_USER="For security reasons you are not allowed to register passwordless authentication tokens on behalf of another user."
PLG_SYSTEM_WEBAUTHN_ERR_CREATE_NO_ATTESTED_DATA="Something went wrong but no further information about the error is available at this time. Please retry registering your authenticator."
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_CREATE_INVALID_LOGIN_REQUEST="Invalid passwordless login request. Something is broken or this is an attempt to hack the site."
PLG_SYSTEM_WEBAUTHN_ERR_CANNOT_FIND_USERNAME="Cannot find the username field in the login module. Sorry, Passwordless authentication will not work on this site unless you use a different login module."
PLG_SYSTEM_WEBAUTHN_ERR_EMPTY_USERNAME="You need to enter your username (but NOT your password) before clicking the Passwordless 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."

View File

@ -0,0 +1,7 @@
; Joomla! Project
; Copyright (C) 2005 - 2019 Open Source Matters. All rights reserved.
; License GNU General Public License version 2 or later; see LICENSE.txt, see LICENSE.php
; Note : All ini files need to be saved as UTF-8
PLG_SYSTEM_WEBAUTHN="System - WebAuthn Passwordless Login"
PLG_SYSTEM_WEBAUTHN_DESCRIPTION="Enables passwordless authentication using the W3C Web Authentication (WebAuthn) API. Please note that the WebAuthn tab in the user profile editor and the WebAuthn login buttons will only be displayed if the user is accessing the site over HTTPS. Furthermore, registering WebAuthn authenticators and using them to log into your site will only work when your site is using a valid certificate, signed by a Certificate Authority the user's browser trusts."

View File

@ -15,6 +15,7 @@ use Joomla\Module\Login\Administrator\Helper\LoginHelper;
$langs = LoginHelper::getLanguageList();
$twofactormethods = AuthenticationHelper::getTwoFactorMethods();
$extraButtons = AuthenticationHelper::getLoginButtons('form-login');
$return = LoginHelper::getReturnUri();
require ModuleHelper::getLayoutPath('mod_login', $params->get('layout', 'default'));

View File

@ -99,6 +99,25 @@ Text::script('MESSAGE');
<?php echo $langs; ?>
</div>
<?php endif; ?>
<?php foreach($extraButtons as $button): ?>
<div class="form-group">
<button type="button"
class="btn btn-secondary btn-block mt-4 <?= $button['class'] ?? '' ?>"
onclick="<?= $button['onclick'] ?>"
title="<?= Text::_($button['label']) ?>"
id="<?= $button['id'] ?>"
>
<?php if (!empty($button['icon'])): ?>
<span class="<?= $button['icon'] ?>"></span>
<?php elseif (!empty($button['image'])): ?>
<?= HTMLHelper::_('image', $button['image'], Text::_('PLG_SYSTEM_WEBAUTHN_LOGIN_DESC'), [
'class' => 'icon',
], true) ?>
<?php endif; ?>
<?= Text::_($button['label']) ?>
</button>
</div>
<?php endforeach; ?>
<div class="form-group">
<button class="btn btn-primary btn-block btn-lg mt-4"
id="btn-login-submit"><?php echo Text::_('JLOGIN'); ?></button>

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

View File

@ -0,0 +1,248 @@
/**
* @package Joomla.Plugin
* @subpackage System.updatenotification
*
* @copyright Copyright (C) 2005 - 2019 Open Source Matters, Inc. All rights reserved.
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
/**
* Converts a simple object containing query string parameters to a single, escaped query string.
* This method is a necessary evil since Joomla.request can only accept data as a string.
*
* @param object {object} A plain object containing the query parameters to pass
* @param thisParamIsThePrefix {string} Prefix for array-type parameters
*
* @returns {string}
*/
function plgSystemWebauthnInterpolateParameters(object, thisParamIsThePrefix) {
const prefix = thisParamIsThePrefix || '';
let encodedString = '';
Object.keys(object).forEach((prop) => {
if (typeof object[prop] !== 'object') {
if (encodedString.length > 0) {
encodedString += '&';
}
if (prefix === '') {
encodedString += `${encodeURIComponent(prop)}=${encodeURIComponent(object[prop])}`;
} else {
encodedString
+= `${encodeURIComponent(prefix)}[${encodeURIComponent(prop)}]=${encodeURIComponent(
object[prop],
)}`;
}
return;
}
// Objects need special handling
encodedString += `${plgSystemWebauthnInterpolateParameters(object[prop], prop)}`;
});
return encodedString;
}
/**
* Finds the first field matching a selector inside a form
*
* @param {HTMLFormElement} elForm The FORM element
* @param {String} fieldSelector The CSS selector to locate the field
*
* @returns {Element|null} NULL when no element is found
*/
function plgSystemWebauthnFindField(elForm, fieldSelector) {
const elInputs = elForm.querySelectorAll(fieldSelector);
if (!elInputs.length) {
return null;
}
return elInputs[0];
}
/**
* Find a form field described by the CSS selector fieldSelector. The field must be inside a <form>
* element which is either the outerElement itself or enclosed by outerElement.
*
* @param {Element} outerElement The element which is either our form or contains our form.
* @param {String} fieldSelector The CSS selector to locate the field
*
* @returns {null|Element} NULL when no element is found
*/
function plgSystemWebauthnLookForField(outerElement, fieldSelector) {
const elElement = outerElement.parentElement;
let elInput = null;
if (elElement.nodeName === 'FORM') {
elInput = plgSystemWebauthnFindField(elElement, fieldSelector);
return elInput;
}
const elForms = elElement.querySelectorAll('form');
if (elForms.length) {
for (let i = 0; i < elForms.length; i += 1) {
elInput = plgSystemWebauthnFindField(elForms[i], fieldSelector);
if (elInput !== null) {
return elInput;
}
}
}
return null;
}
/**
* A simple error handler.
*
* @param {String} message
*/
function plgSystemWebauthnHandleLoginError(message) {
alert(message);
}
/**
* Handles the browser response for the user interaction with the authenticator. Redirects to an
* 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.
*/
function plgSystemWebauthnHandleLoginChallenge(publicKey, callbackUrl) {
function arrayToBase64String(a) {
return btoa(String.fromCharCode(...a));
}
function base64url2base64(input) {
let output = input
.replace(/-/g, '+')
.replace(/_/g, '/');
const pad = output.length % 4;
if (pad) {
if (pad === 1) {
throw new Error('InvalidLengthError: Input base64url string is the wrong length to determine padding');
}
output += new Array(5 - pad).join('=');
}
return output;
}
if (!publicKey.challenge) {
plgSystemWebauthnHandleLoginError(Joomla.JText._('PLG_SYSTEM_WEBAUTHN_ERR_INVALID_USERNAME'));
return;
}
publicKey.challenge = Uint8Array.from(
window.atob(base64url2base64(publicKey.challenge)), c => c.charCodeAt(0),
);
if (publicKey.allowCredentials) {
publicKey.allowCredentials = publicKey.allowCredentials.map((data) => {
data.id = Uint8Array.from(window.atob(base64url2base64(data.id)), c => c.charCodeAt(0));
return data;
});
}
navigator.credentials.get({ publicKey })
.then((data) => {
const publicKeyCredential = {
id: data.id,
type: data.type,
rawId: arrayToBase64String(new Uint8Array(data.rawId)),
response: {
authenticatorData: arrayToBase64String(new Uint8Array(data.response.authenticatorData)),
clientDataJSON: arrayToBase64String(new Uint8Array(data.response.clientDataJSON)),
signature: arrayToBase64String(new Uint8Array(data.response.signature)),
userHandle: data.response.userHandle ? arrayToBase64String(
new Uint8Array(data.response.userHandle),
) : null,
},
};
// Send the response to your server
window.location = `${callbackUrl}&option=com_ajax&group=system&plugin=webauthn&`
+ `format=raw&akaction=login&encoding=redirect&data=${
btoa(JSON.stringify(publicKeyCredential))}`;
})
.catch((error) => {
// Example: timeout, interaction refused...
plgSystemWebauthnHandleLoginError(error);
});
}
/**
* Initialize the passwordless login, going through the server to get the registered certificates
* 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
function plgSystemWebauthnLogin(formId, callbackUrl) {
// Get the username
const elFormContainer = document.getElementById(formId);
const elUsername = plgSystemWebauthnLookForField(elFormContainer, 'input[name=username]');
const elReturn = plgSystemWebauthnLookForField(elFormContainer, 'input[name=return]');
if (elUsername === null) {
alert(Joomla.JText._('PLG_SYSTEM_WEBAUTHN_ERR_CANNOT_FIND_USERNAME'));
return false;
}
const username = elUsername.value;
const returnUrl = elReturn ? elReturn.value : null;
// No username? We cannot proceed. We need a username to find the acceptable public keys :(
if (username === '') {
alert(Joomla.JText._('PLG_SYSTEM_WEBAUTHN_ERR_EMPTY_USERNAME'));
return false;
}
// Get the Public Key Credential Request Options (challenge and acceptable public keys)
const postBackData = {
option: 'com_ajax',
group: 'system',
plugin: 'webauthn',
format: 'raw',
akaction: 'challenge',
encoding: 'raw',
username,
returnUrl,
};
Joomla.request({
url: callbackUrl,
method: 'POST',
data: plgSystemWebauthnInterpolateParameters(postBackData),
onSuccess(rawResponse) {
let jsonData = {};
try {
jsonData = JSON.parse(rawResponse);
} catch (e) {
/**
* In case of JSON decoding failure fall through; the error will be handled in the login
* challenge handler called below.
*/
}
plgSystemWebauthnHandleLoginChallenge(jsonData, callbackUrl);
},
onError: (xhr) => {
plgSystemWebauthnHandleLoginError(`${xhr.status} ${xhr.statusText}`);
},
});
return false;
}

View File

@ -0,0 +1,355 @@
/**
* @package Joomla.Plugin
* @subpackage System.updatenotification
*
* @copyright Copyright (C) 2005 - 2019 Open Source Matters, Inc. All rights reserved.
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
/**
* Converts a simple object containing query string parameters to a single, escaped query string.
* This method is a necessary evil since Joomla.request can only accept data as a string.
*
* @param object {object} A plain object containing the query parameters to pass
* @param thisParamIsThePrefix {string} Prefix for array-type parameters
*
* @returns {string}
*/
function plgSystemWebauthnInterpolateParameters(object, thisParamIsThePrefix) {
const prefix = thisParamIsThePrefix || '';
let encodedString = '';
Object.keys(object).forEach((prop) => {
if (typeof object[prop] !== 'object') {
if (encodedString.length > 0) {
encodedString += '&';
}
if (prefix === '') {
encodedString += `${encodeURIComponent(prop)}=${encodeURIComponent(object[prop])}`;
} else {
encodedString
+= `${encodeURIComponent(prefix)}[${encodeURIComponent(prop)}]=${encodeURIComponent(
object[prop],
)}`;
}
return;
}
// Objects need special handling
encodedString += `${plgSystemWebauthnInterpolateParameters(object[prop], prop)}`;
});
return encodedString;
}
/**
* A simple error handler
*
* @param {String} message
*/
function plgSystemWebauthnHandleCreationError(message) {
alert(message);
}
/**
* Ask the user to link an authenticator using the provided public key (created server-side). 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
function plgSystemWebauthnCreateCredentials(storeID, interfaceSelector) {
// Make sure the browser supports Webauthn
if (!('credentials' in navigator)) {
alert(Joomla.JText._('PLG_SYSTEM_WEBAUTHN_ERR_NO_BROWSER_SUPPORT'));
return;
}
// Extract the configuration from the store
const elStore = document.getElementById(storeID);
if (!elStore) {
return;
}
const publicKey = JSON.parse(atob(elStore.dataset.public_key));
const postURL = atob(elStore.dataset.postback_url);
function arrayToBase64String(a) {
return btoa(String.fromCharCode(...a));
}
function base64url2base64(input) {
let output = input
.replace(/-/g, '+')
.replace(/_/g, '/');
const pad = output.length % 4;
if (pad) {
if (pad === 1) {
throw new Error('InvalidLengthError: Input base64url string is the wrong length to determine padding');
}
output += new Array(5 - pad).join('=');
}
return output;
}
// Convert the public key information to a format usable by the browser's credentials manager
publicKey.challenge = Uint8Array.from(
window.atob(base64url2base64(publicKey.challenge)), c => c.charCodeAt(0),
);
publicKey.user.id = Uint8Array.from(window.atob(publicKey.user.id), c => c.charCodeAt(0));
if (publicKey.excludeCredentials) {
publicKey.excludeCredentials = publicKey.excludeCredentials.map((data) => {
data.id = Uint8Array.from(window.atob(base64url2base64(data.id)), c => c.charCodeAt(0));
return data;
});
}
// Ask the browser to prompt the user for their authenticator
navigator.credentials.create({ publicKey })
.then((data) => {
const publicKeyCredential = {
id: data.id,
type: data.type,
rawId: arrayToBase64String(new Uint8Array(data.rawId)),
response: {
clientDataJSON: arrayToBase64String(new Uint8Array(data.response.clientDataJSON)),
attestationObject: arrayToBase64String(new Uint8Array(data.response.attestationObject)),
},
};
// Send the response to your server
const postBackData = {
option: 'com_ajax',
group: 'system',
plugin: 'webauthn',
format: 'raw',
akaction: 'create',
encoding: 'raw',
data: btoa(JSON.stringify(publicKeyCredential)),
};
Joomla.request({
url: postURL,
method: 'POST',
data: plgSystemWebauthnInterpolateParameters(postBackData),
onSuccess(responseHTML) {
const elements = document.querySelectorAll(interfaceSelector);
if (!elements) {
return;
}
const elContainer = elements[0];
elContainer.outerHTML = responseHTML;
},
onError: (xhr) => {
plgSystemWebauthnHandleCreationError(`${xhr.status} ${xhr.statusText}`);
},
});
})
.catch((error) => {
// An error occurred: timeout, request to provide the authenticator refused, hardware /
// software error...
plgSystemWebauthnHandleCreationError(error);
});
}
/**
* Edit label 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
function 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);
// Find the UI elements
const elTR = that.parentElement.parentElement;
const credentialId = elTR.dataset.credential_id;
const elTDs = elTR.querySelectorAll('td');
const elLabelTD = elTDs[0];
const elButtonsTD = elTDs[1];
const elButtons = elButtonsTD.querySelectorAll('button');
const elEdit = elButtons[0];
const elDelete = elButtons[1];
// Show the editor
const oldLabel = elLabelTD.innerText;
const elInput = document.createElement('input');
elInput.type = 'text';
elInput.name = 'label';
elInput.defaultValue = oldLabel;
const elSave = document.createElement('button');
elSave.className = 'btn btn-success btn-sm';
elSave.innerText = Joomla.JText._('PLG_SYSTEM_WEBAUTHN_MANAGE_BTN_SAVE_LABEL');
elSave.addEventListener('click', () => {
const elNewLabel = elInput.value;
if (elNewLabel !== '') {
const postBackData = {
option: 'com_ajax',
group: 'system',
plugin: 'webauthn',
format: 'json',
encoding: 'json',
akaction: 'savelabel',
credential_id: credentialId,
new_label: elNewLabel,
};
Joomla.request({
url: postURL,
method: 'POST',
data: plgSystemWebauthnInterpolateParameters(postBackData),
onSuccess(rawResponse) {
let result = false;
try {
result = JSON.parse(rawResponse);
} catch (exception) {
result = (rawResponse === 'true');
}
if (result !== true) {
plgSystemWebauthnHandleCreationError(
Joomla.JText._('PLG_SYSTEM_WEBAUTHN_ERR_LABEL_NOT_SAVED'),
);
}
// alert(Joomla.JText._('PLG_SYSTEM_WEBAUTHN_MSG_SAVED_LABEL'));
},
onError: (xhr) => {
plgSystemWebauthnHandleCreationError(
`${Joomla.JText._('PLG_SYSTEM_WEBAUTHN_ERR_LABEL_NOT_SAVED')
} -- ${xhr.status} ${xhr.statusText}`,
);
},
});
}
elLabelTD.innerText = elNewLabel;
elEdit.disabled = false;
elDelete.disabled = false;
return false;
}, false);
const elCancel = document.createElement('button');
elCancel.className = 'btn btn-danger btn-sm';
elCancel.innerText = Joomla.JText._('PLG_SYSTEM_WEBAUTHN_MANAGE_BTN_CANCEL_LABEL');
elCancel.addEventListener('click', () => {
elLabelTD.innerText = oldLabel;
elEdit.disabled = false;
elDelete.disabled = false;
return false;
}, false);
elLabelTD.innerHTML = '';
elLabelTD.appendChild(elInput);
elLabelTD.appendChild(elSave);
elLabelTD.appendChild(elCancel);
elEdit.disabled = true;
elDelete.disabled = true;
return false;
}
/**
* 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
function plgSystemWebauthnDelete(that, storeID) {
// Extract the configuration from the store
const elStore = document.getElementById(storeID);
if (!elStore) {
return false;
}
const postURL = atob(elStore.dataset.postback_url);
// Find the UI elements
const elTR = that.parentElement.parentElement;
const credentialId = elTR.dataset.credential_id;
const elTDs = elTR.querySelectorAll('td');
const elButtonsTD = elTDs[1];
const elButtons = elButtonsTD.querySelectorAll('button');
const elEdit = elButtons[0];
const elDelete = elButtons[1];
elEdit.disabled = true;
elDelete.disabled = true;
// Delete the record
const postBackData = {
option: 'com_ajax',
group: 'system',
plugin: 'webauthn',
format: 'json',
encoding: 'json',
akaction: 'delete',
credential_id: credentialId,
};
Joomla.request({
url: postURL,
method: 'POST',
data: plgSystemWebauthnInterpolateParameters(postBackData),
onSuccess(rawResponse) {
let result = false;
try {
result = JSON.parse(rawResponse);
} catch (e) {
result = (rawResponse === 'true');
}
if (result !== true) {
plgSystemWebauthnHandleCreationError(
Joomla.JText._('PLG_SYSTEM_WEBAUTHN_ERR_NOT_DELETED'),
);
return;
}
elTR.parentElement.removeChild(elTR);
},
onError: (xhr) => {
elEdit.disabled = false;
elDelete.disabled = false;
plgSystemWebauthnHandleCreationError(
`${Joomla.JText._('PLG_SYSTEM_WEBAUTHN_ERR_NOT_DELETED')
} -- ${xhr.status} ${xhr.statusText}`,
);
},
});
return false;
}

View File

@ -0,0 +1,22 @@
button[class*=plg_system_webauthn_login_button] {
span[class*=icon] {
display: inline-block;
width: 1em;
font-size: 1.25em;
text-align: center;
vertical-align: sub;
}
img[class*=icon] {
display: inline-block;
width: 1.25em;
max-height: 1.25em;
font-size: 1.5em;
text-align: center;
vertical-align: middle;
}
span[class*=icon]:not(:last-child) {
margin-right: .5em;
}
}

View File

@ -70,6 +70,14 @@ class HtmlView extends BaseHtmlView
*/
protected $tfa = '';
/**
* Additional buttons to show on the login page
*
* @var array
* @since 4.0.0
*/
protected $extraButtons = [];
/**
* Method to display the view.
*
@ -105,6 +113,8 @@ class HtmlView extends BaseHtmlView
$tfa = AuthenticationHelper::getTwoFactorMethods();
$this->tfa = is_array($tfa) && count($tfa) > 1;
$this->extraButtons = AuthenticationHelper::getLoginButtons('com-users-login__form');
// Escape strings for HTML output
$this->pageclass_sfx = htmlspecialchars($this->params->get('pageclass_sfx'), ENT_COMPAT, 'UTF-8');

View File

@ -46,7 +46,7 @@ $usersConfig = ComponentHelper::getParams('com_users');
</div>
<?php endif; ?>
<form action="<?php echo Route::_('index.php?option=com_users&task=user.login'); ?>" method="post" class="com-users-login__form form-validate form-horizontal well">
<form action="<?php echo Route::_('index.php?option=com_users&task=user.login'); ?>" method="post" class="com-users-login__form form-validate form-horizontal well" id="com-users-login__form">
<fieldset>
<?php echo $this->form->renderFieldset('credentials', ['class' => 'com-users-login__input']); ?>
@ -68,6 +68,28 @@ $usersConfig = ComponentHelper::getParams('com_users');
</div>
<?php endif; ?>
<?php foreach ($this->extraButtons as $button): ?>
<div class="com-users-login__submit control-group">
<div class="controls">
<button type="button"
class="btn btn-secondary <?= $button['class'] ?? '' ?>"
onclick="<?= $button['onclick'] ?>"
title="<?= Text::_($button['label']) ?>"
id="<?= $button['id'] ?>"
>
<?php if (!empty($button['icon'])): ?>
<span class="<?= $button['icon'] ?>"></span>
<?php elseif (!empty($button['image'])): ?>
<?= HTMLHelper::_('image', $button['image'], Text::_('PLG_SYSTEM_WEBAUTHN_LOGIN_DESC'), [
'class' => 'icon',
], true) ?>
<?php endif; ?>
<?= Text::_($button['label']) ?>
</button>
</div>
</div>
<?php endforeach; ?>
<div class="com-users-login__submit control-group">
<div class="controls">
<button type="submit" class="btn btn-primary">

View File

@ -99,7 +99,8 @@
"ext-json": "*",
"ext-simplexml": "*",
"psr/log": "~1.0",
"ext-gd": "*"
"ext-gd": "*",
"web-auth/webauthn-lib": "2.1.*"
},
"require-dev": {
"phpunit/phpunit": "~8.0",

637
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "954a78e558a7608db7a654da546fc8e2",
"content-hash": "56c24f5100bbf31f25d9d05f3ce40d49",
"packages": [
{
"name": "algo26-matthias/idna-convert",
@ -58,6 +58,68 @@
],
"time": "2019-12-05T12:27:39+00:00"
},
{
"name": "beberlei/assert",
"version": "v3.2.7",
"source": {
"type": "git",
"url": "https://github.com/beberlei/assert.git",
"reference": "d63a6943fc4fd1a2aedb65994e3548715105abcf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/beberlei/assert/zipball/d63a6943fc4fd1a2aedb65994e3548715105abcf",
"reference": "d63a6943fc4fd1a2aedb65994e3548715105abcf",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-simplexml": "*",
"php": "^7"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "*",
"phpstan/phpstan-shim": "*",
"phpunit/phpunit": ">=6.0.0 <8"
},
"suggest": {
"ext-intl": "Needed to allow Assertion::count(), Assertion::isCountable(), Assertion::minCount(), and Assertion::maxCount() to operate on ResourceBundles"
},
"type": "library",
"autoload": {
"psr-4": {
"Assert\\": "lib/Assert"
},
"files": [
"lib/Assert/functions.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Benjamin Eberlei",
"email": "kontakt@beberlei.de",
"role": "Lead Developer"
},
{
"name": "Richard Quadling",
"email": "rquadling@gmail.com",
"role": "Collaborator"
}
],
"description": "Thin assertion library for input validation in business models.",
"keywords": [
"assert",
"assertion",
"validation"
],
"time": "2019-12-19T17:51:41+00:00"
},
{
"name": "composer/ca-bundle",
"version": "1.2.6",
@ -244,6 +306,75 @@
],
"time": "2017-07-22T12:18:28+00:00"
},
{
"name": "fgrosse/phpasn1",
"version": "v2.1.1",
"source": {
"type": "git",
"url": "https://github.com/fgrosse/PHPASN1.git",
"reference": "7ebf2a09084a7bbdb7b879c66fdf7ad80461bbe8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/fgrosse/PHPASN1/zipball/7ebf2a09084a7bbdb7b879c66fdf7ad80461bbe8",
"reference": "7ebf2a09084a7bbdb7b879c66fdf7ad80461bbe8",
"shasum": ""
},
"require": {
"php": ">=7.0.0"
},
"require-dev": {
"phpunit/phpunit": "~6.3",
"satooshi/php-coveralls": "~2.0"
},
"suggest": {
"ext-gmp": "GMP is the preferred extension for big integer calculations",
"php-curl": "For loading OID information from the web if they have not bee defined statically"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-4": {
"FG\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Friedrich Große",
"email": "friedrich.grosse@gmail.com",
"homepage": "https://github.com/FGrosse",
"role": "Author"
},
{
"name": "All contributors",
"homepage": "https://github.com/FGrosse/PHPASN1/contributors"
}
],
"description": "A PHP Framework that allows you to encode and decode arbitrary ASN.1 structures using the ITU-T X.690 Encoding Rules.",
"homepage": "https://github.com/FGrosse/PHPASN1",
"keywords": [
"DER",
"asn.1",
"asn1",
"ber",
"binary",
"decoding",
"encoding",
"x.509",
"x.690",
"x509",
"x690"
],
"time": "2018-12-02T01:34:34+00:00"
},
{
"name": "fig/link-util",
"version": "1.1.0",
@ -1794,6 +1925,68 @@
],
"time": "2019-11-24T09:46:11+00:00"
},
{
"name": "nyholm/psr7",
"version": "1.2.1",
"source": {
"type": "git",
"url": "https://github.com/Nyholm/psr7.git",
"reference": "55ff6b76573f5b242554c9775792bd59fb52e11c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Nyholm/psr7/zipball/55ff6b76573f5b242554c9775792bd59fb52e11c",
"reference": "55ff6b76573f5b242554c9775792bd59fb52e11c",
"shasum": ""
},
"require": {
"php": "^7.1",
"php-http/message-factory": "^1.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.0"
},
"provide": {
"psr/http-factory-implementation": "1.0",
"psr/http-message-implementation": "1.0"
},
"require-dev": {
"http-interop/http-factory-tests": "dev-master",
"php-http/psr7-integration-tests": "dev-master",
"phpunit/phpunit": "^7.5"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0-dev"
}
},
"autoload": {
"psr-4": {
"Nyholm\\Psr7\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com"
},
{
"name": "Martijn van der Ven",
"email": "martijn@vanderven.se"
}
],
"description": "A fast PHP7 implementation of PSR-7",
"homepage": "http://tnyholm.se",
"keywords": [
"psr-17",
"psr-7"
],
"time": "2019-09-05T13:24:16+00:00"
},
{
"name": "ozdemirburak/iris",
"version": "1.2.1",
@ -1980,6 +2173,56 @@
],
"time": "2019-05-09T23:30:36+00:00"
},
{
"name": "php-http/message-factory",
"version": "v1.0.2",
"source": {
"type": "git",
"url": "https://github.com/php-http/message-factory.git",
"reference": "a478cb11f66a6ac48d8954216cfed9aa06a501a1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-http/message-factory/zipball/a478cb11f66a6ac48d8954216cfed9aa06a501a1",
"reference": "a478cb11f66a6ac48d8954216cfed9aa06a501a1",
"shasum": ""
},
"require": {
"php": ">=5.4",
"psr/http-message": "^1.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0-dev"
}
},
"autoload": {
"psr-4": {
"Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com"
}
],
"description": "Factory interfaces for PSR-7 HTTP Message",
"homepage": "http://php-http.org",
"keywords": [
"factory",
"http",
"message",
"stream",
"uri"
],
"time": "2015-12-19T14:08:53+00:00"
},
{
"name": "phpmailer/phpmailer",
"version": "v6.1.4",
@ -2338,6 +2581,212 @@
],
"time": "2019-11-01T11:05:21+00:00"
},
{
"name": "ramsey/uuid",
"version": "3.9.3",
"source": {
"type": "git",
"url": "https://github.com/ramsey/uuid.git",
"reference": "7e1633a6964b48589b142d60542f9ed31bd37a92"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ramsey/uuid/zipball/7e1633a6964b48589b142d60542f9ed31bd37a92",
"reference": "7e1633a6964b48589b142d60542f9ed31bd37a92",
"shasum": ""
},
"require": {
"ext-json": "*",
"paragonie/random_compat": "^1 | ^2 | 9.99.99",
"php": "^5.4 | ^7 | ^8",
"symfony/polyfill-ctype": "^1.8"
},
"replace": {
"rhumsaa/uuid": "self.version"
},
"require-dev": {
"codeception/aspect-mock": "^1 | ^2",
"doctrine/annotations": "^1.2",
"goaop/framework": "1.0.0-alpha.2 | ^1 | ^2.1",
"jakub-onderka/php-parallel-lint": "^1",
"mockery/mockery": "^0.9.11 | ^1",
"moontoast/math": "^1.1",
"paragonie/random-lib": "^2",
"php-mock/php-mock-phpunit": "^0.3 | ^1.1",
"phpunit/phpunit": "^4.8 | ^5.4 | ^6.5",
"squizlabs/php_codesniffer": "^3.5"
},
"suggest": {
"ext-ctype": "Provides support for PHP Ctype functions",
"ext-libsodium": "Provides the PECL libsodium extension for use with the SodiumRandomGenerator",
"ext-openssl": "Provides the OpenSSL extension for use with the OpenSslGenerator",
"ext-uuid": "Provides the PECL UUID extension for use with the PeclUuidTimeGenerator and PeclUuidRandomGenerator",
"moontoast/math": "Provides support for converting UUID to 128-bit integer (in string form).",
"paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter",
"ramsey/uuid-console": "A console application for generating UUIDs with ramsey/uuid",
"ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type."
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Ramsey\\Uuid\\": "src/"
},
"files": [
"src/functions.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ben Ramsey",
"email": "ben@benramsey.com",
"homepage": "https://benramsey.com"
},
{
"name": "Marijn Huizendveld",
"email": "marijn.huizendveld@gmail.com"
},
{
"name": "Thibaud Fabre",
"email": "thibaud@aztech.io"
}
],
"description": "Formerly rhumsaa/uuid. A PHP 5.4+ library for generating RFC 4122 version 1, 3, 4, and 5 universally unique identifiers (UUID).",
"homepage": "https://github.com/ramsey/uuid",
"keywords": [
"guid",
"identifier",
"uuid"
],
"time": "2020-02-21T04:36:14+00:00"
},
{
"name": "spomky-labs/base64url",
"version": "v2.0.1",
"source": {
"type": "git",
"url": "https://github.com/Spomky-Labs/base64url.git",
"reference": "3eb46a1de803f0078962d910e3a2759224a68c61"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Spomky-Labs/base64url/zipball/3eb46a1de803f0078962d910e3a2759224a68c61",
"reference": "3eb46a1de803f0078962d910e3a2759224a68c61",
"shasum": ""
},
"require": {
"php": "^7.1"
},
"require-dev": {
"php-coveralls/php-coveralls": "^2.0",
"phpunit/phpunit": "^7.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Base64Url\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky-Labs/base64url/contributors"
}
],
"description": "Base 64 URL Safe Encoding/Decoding PHP Library",
"homepage": "https://github.com/Spomky-Labs/base64url",
"keywords": [
"base64",
"rfc4648",
"safe",
"url"
],
"time": "2018-08-16T15:44:20+00:00"
},
{
"name": "spomky-labs/cbor-php",
"version": "v1.0.8",
"source": {
"type": "git",
"url": "https://github.com/Spomky-Labs/cbor-php.git",
"reference": "575a66dc406575b030e3ba541b33447842f93185"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/575a66dc406575b030e3ba541b33447842f93185",
"reference": "575a66dc406575b030e3ba541b33447842f93185",
"shasum": ""
},
"require": {
"beberlei/assert": "^3.2",
"ext-gmp": "*",
"php": "^7.1|^8.0",
"spomky-labs/base64url": "^1.0|^2.0"
},
"require-dev": {
"php-coveralls/php-coveralls": "^2.0",
"phpstan/phpstan": "^0.12",
"phpstan/phpstan-beberlei-assert": "^0.12",
"phpstan/phpstan-deprecation-rules": "^0.12",
"phpstan/phpstan-phpunit": "^0.12",
"phpstan/phpstan-strict-rules": "^0.12",
"phpunit/phpunit": "^7.5|^8.0",
"rector/rector": "^0.5|^0.6",
"symfony/var-dumper": "^4.3|^5.0"
},
"suggest": {
"ext-bcmath": "BCMath extension needed to handle the Big Float and Decimal Fraction Tags"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"CBOR\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky"
},
{
"name": "All contributors",
"homepage": "https://github.com/Spomky-Labs/cbor-php/contributors"
}
],
"description": "CBOR Encoder/Decoder for PHP",
"keywords": [
"Concise Binary Object Representation",
"RFC7049",
"cbor"
],
"time": "2020-02-18T08:37:37+00:00"
},
{
"name": "symfony/console",
"version": "v4.3.11",
@ -3583,6 +4032,192 @@
],
"time": "2020-01-16T14:56:50+00:00"
},
{
"name": "web-auth/cose-lib",
"version": "v2.1.7",
"source": {
"type": "git",
"url": "https://github.com/web-auth/cose-lib.git",
"reference": "8d1c37bac6e5db8d502b7735448d416f05fb4c70"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/web-auth/cose-lib/zipball/8d1c37bac6e5db8d502b7735448d416f05fb4c70",
"reference": "8d1c37bac6e5db8d502b7735448d416f05fb4c70",
"shasum": ""
},
"require": {
"beberlei/assert": "^3.0",
"ext-json": "*",
"ext-mbstring": "*",
"ext-openssl": "*",
"fgrosse/phpasn1": "^2.1",
"php": "^7.2"
},
"type": "library",
"extra": {
"branch-alias": {
"v1.0": "1.0.x-dev",
"v1.1": "1.1.x-dev",
"v1.2": "1.2.x-dev",
"v2.0": "2.0.x-dev",
"v2.1": "2.1.x-dev"
}
},
"autoload": {
"psr-4": {
"Cose\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky"
},
{
"name": "All contributors",
"homepage": "https://github.com/web-auth/cose/contributors"
}
],
"description": "CBOR Object Signing and Encryption (COSE) For PHP",
"homepage": "https://github.com/web-auth",
"keywords": [
"COSE",
"RFC8152"
],
"time": "2019-09-04T20:53:12+00:00"
},
{
"name": "web-auth/metadata-service",
"version": "v2.1.7",
"source": {
"type": "git",
"url": "https://github.com/web-auth/webauthn-metadata-service.git",
"reference": "5fc754d00dfa05913260dc3781227dfa8ed7dbdd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/web-auth/webauthn-metadata-service/zipball/5fc754d00dfa05913260dc3781227dfa8ed7dbdd",
"reference": "5fc754d00dfa05913260dc3781227dfa8ed7dbdd",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^7.1",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0"
},
"type": "library",
"extra": {
"branch-alias": {
"v2.1": "2.1.x-dev"
}
},
"autoload": {
"psr-4": {
"Webauthn\\MetadataService\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky"
},
{
"name": "All contributors",
"homepage": "https://github.com/web-auth/metadata-service/contributors"
}
],
"description": "Metadata Service for FIDO2/Webauthn",
"homepage": "https://github.com/web-auth",
"keywords": [
"FIDO2",
"fido",
"webauthn"
],
"time": "2019-09-04T20:53:12+00:00"
},
{
"name": "web-auth/webauthn-lib",
"version": "v2.1.7",
"source": {
"type": "git",
"url": "https://github.com/web-auth/webauthn-lib.git",
"reference": "4cd346f2ef4d282296e503b7b1b3ef200347437b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/4cd346f2ef4d282296e503b7b1b3ef200347437b",
"reference": "4cd346f2ef4d282296e503b7b1b3ef200347437b",
"shasum": ""
},
"require": {
"beberlei/assert": "^3.0",
"ext-json": "*",
"ext-mbstring": "*",
"ext-openssl": "*",
"nyholm/psr7": "^1.1",
"php": "^7.2",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.0",
"ramsey/uuid": "^3.8",
"spomky-labs/base64url": "^2.0",
"spomky-labs/cbor-php": "^1.0.2",
"web-auth/cose-lib": "self.version",
"web-auth/metadata-service": "self.version"
},
"suggest": {
"web-token/jwt-signature-algorithm-ecdsa": "Recommended for the AndroidSafetyNet Attestation Statement support",
"web-token/jwt-signature-algorithm-eddsa": "Recommended for the AndroidSafetyNet Attestation Statement support",
"web-token/jwt-signature-algorithm-rsa": "Mandatory for the AndroidSafetyNet Attestation Statement support"
},
"type": "library",
"extra": {
"branch-alias": {
"v1.0": "1.0.x-dev",
"v1.1": "1.1.x-dev",
"v1.2": "1.2.x-dev",
"v2.0": "2.0.x-dev",
"v2.1": "2.1.x-dev"
}
},
"autoload": {
"psr-4": {
"Webauthn\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky"
},
{
"name": "All contributors",
"homepage": "https://github.com/web-auth/webauthn-library/contributors"
}
],
"description": "FIDO2/Webauthn Support For PHP",
"homepage": "https://github.com/web-auth",
"keywords": [
"FIDO2",
"fido",
"webauthn"
],
"time": "2019-09-09T12:04:09+00:00"
},
{
"name": "willdurand/negotiation",
"version": "v2.3.1",

View File

@ -722,7 +722,8 @@ INSERT INTO `#__extensions` (`package_id`, `name`, `type`, `element`, `folder`,
(0, 'cassiopeia', 'template', 'cassiopeia', '', 0, 1, 1, 0, '', '{"logoFile":"","fluidContainer":"0","sidebarLeftWidth":"3","sidebarRightWidth":"3"}', 0, NULL, 0, 0),
(0, 'plg_fields_subfields', 'plugin', 'subfields', 'fields', 0, 1, 1, 0, '', '', 0, NULL, 0, 0),
(0, 'files_joomla', 'file', 'joomla', '', 0, 1, 1, 1, '', '', 0, NULL, 0, 0),
(0, 'English (en-GB) Language Pack', 'package', 'pkg_en-GB', '', 0, 1, 1, 1, '', '', 0, NULL, 0, 0);
(0, 'English (en-GB) Language Pack', 'package', 'pkg_en-GB', '', 0, 1, 1, 1, '', '', 0, NULL, 0, 0),
(0, 'plg_system_webauthn', 'plugin', 'webauthn', 'system', 0, 1, 1, 0, '', '{}', 0, NULL, 0, 0);
INSERT INTO `#__extensions` (`package_id`, `name`, `type`, `element`, `folder`, `client_id`, `enabled`, `access`, `protected`, `manifest_cache`, `params`, `checked_out`, `checked_out_time`, `ordering`, `state`)
SELECT `extension_id`, 'English (en-GB)', 'language', 'en-GB', '', 0, 1, 1, 1, '', '', 0, NULL, 0, 0 FROM `#__extensions` WHERE `name` = 'English (en-GB) Language Pack';
@ -2281,6 +2282,24 @@ INSERT INTO `#__viewlevels` (`id`, `title`, `ordering`, `rules`) VALUES
-- --------------------------------------------------------
--
-- Table structure for table `#__webauthn_credentials#__webauthn_credentials`
--
CREATE TABLE IF NOT EXISTS `#__webauthn_credentials`
(
`id` VARCHAR(1000) NOT NULL COMMENT 'Credential ID',
`user_id` VARCHAR(128) NOT NULL COMMENT 'User handle',
`label` VARCHAR(190) NOT NULL COMMENT 'Human readable label',
`credential` MEDIUMTEXT NOT NULL COMMENT 'Credential source data, JSON format',
PRIMARY KEY (`id`(100)),
INDEX (`user_id`(100))
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
DEFAULT COLLATE = utf8mb4_unicode_ci;
-- --------------------------------------------------------
--
-- Table structure for table `#__workflows`
--

View File

@ -733,7 +733,9 @@ INSERT INTO "#__extensions" ("package_id", "name", "type", "element", "folder",
(0, 'cassiopeia', 'template', 'cassiopeia', '', 0, 1, 1, 0, '', '{"logoFile":"","fluidContainer":"0","sidebarLeftWidth":"3","sidebarRightWidth":"3"}', 0, NULL, 0, 0),
(0, 'plg_fields_subfields', 'plugin', 'subfields', 'fields', 0, 1, 1, 0, '', '', 0, NULL, 0, 0),
(0, 'files_joomla', 'file', 'joomla', '', 0, 1, 1, 1, '', '', 0, NULL, 0, 0),
(0, 'English (en-GB) Language Pack', 'package', 'pkg_en-GB', '', 0, 1, 1, 1, '', '', 0, NULL, 0, 0);
(0, 'English (en-GB) Language Pack', 'package', 'pkg_en-GB', '', 0, 1, 1, 1, '', '', 0, NULL, 0, 0),
(0, 'plg_system_webauthn', 'plugin', 'webauthn', 'system', 0, 1, 1, 0, '', '{}', 0, NULL, 8, 0);
INSERT INTO "#__extensions" ("package_id", "name", "type", "element", "folder", "client_id", "enabled", "access", "protected", "manifest_cache", "params", "checked_out", "checked_out_time", "ordering", "state")
SELECT "extension_id", 'English (en-GB)', 'language', 'en-GB', '', 0, 1, 1, 1, '', '', 0, NULL, 0, 0 FROM "#__extensions" WHERE "name" = 'English (en-GB) Language Pack';
@ -2283,6 +2285,21 @@ INSERT INTO "#__viewlevels" ("id", "title", "ordering", "rules") VALUES
SELECT setval('#__viewlevels_id_seq', 7, false);
--
-- Table structure for table "#__webauthn_credentials"
--
CREATE TABLE IF NOT EXISTS "#__webauthn_credentials"
(
"id" varchar(1000) NOT NULL,
"user_id" varchar(128) NOT NULL,
"label" varchar(190) NOT NULL,
"credential" TEXT NOT NULL,
PRIMARY KEY ("id")
);
CREATE INDEX "#__webauthn_credentials_user_id" ON "#__webauthn_credentials" ("user_id");
--
-- Table structure for table "#__workflows"
--

View File

@ -0,0 +1,135 @@
<?php
/**
* @package Joomla.Plugin
* @subpackage System.updatenotification
*
* @copyright Copyright (C) 2005 - 2019 Open Source Matters, Inc. All rights reserved.
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
defined('JPATH_BASE') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Layout\FileLayout;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\User\User;
use Joomla\CMS\User\UserHelper;
use Joomla\Plugin\System\Webauthn\Helper\CredentialsCreation;
use Joomla\Plugin\System\Webauthn\Helper\Joomla;
/**
* Passwordless Login management interface
*
*
* Generic data
*
* @var FileLayout $this The Joomla layout renderer
* @var array $displayData The data in array format. DO NOT USE.
*
* Layout specific data
*
* @var User $user The Joomla user whose passwordless login we are managing
* @var bool $allow_add Are we allowed to add passwordless login methods
* @var array $credentials The already stored credentials for the user
* @var string $error Any error messages
*/
// Extract the data. Do not remove until the unset() line.
try
{
$app = Factory::getApplication();
$loggedInUser = $app->getIdentity();
}
catch (Exception $e)
{
$loggedInUser = new User;
}
$defaultDisplayData = [
'user' => $loggedInUser,
'allow_add' => false,
'credentials' => [],
'error' => '',
];
extract(array_merge($defaultDisplayData, $displayData));
HTMLHelper::_('stylesheet', 'plg_system_webauthn/backend.css', ['relative' => true]);
/**
* Why not push these configuration variables directly to JavaScript?
*
* We need to reload them every time we return from an attempt to authorize an authenticator. Whenever that
* happens we push raw HTML to the page. However, any SCRIPT tags in that HTML do not get parsed, i.e. they
* do not replace existing values. This causes any retries to fail. By using a data storage object we circumvent
* that problem.
*/
$randomId = 'plg_system_webauthn_' . UserHelper::genRandomPassword(32);
// phpcs:ignore
$publicKey = $allow_add ? base64_encode(CredentialsCreation::createPublicKey($user)) : '{}';
$postbackURL = base64_encode(rtrim(Uri::base(), '/') . '/index.php?' . Joomla::getToken() . '=1');
?>
<div class="plg_system_webauthn" id="plg_system_webauthn-management-interface">
<span id="<?php echo $randomId ?>"
data-public_key="<?php echo $publicKey ?>"
data-postback_url="<?php echo $postbackURL ?>"
></span>
<?php // phpcs:ignore
if (is_string($error) && !empty($error)): ?>
<div class="alert alert-danger">
<?php echo htmlentities($error) ?>
</div>
<?php endif; ?>
<table class="table table-striped">
<thead class="thead-dark">
<tr>
<th><?php echo Text::_('PLG_SYSTEM_WEBAUTHN_MANAGE_FIELD_KEYLABEL_LABEL') ?></th>
<th><?php echo Text::_('PLG_SYSTEM_WEBAUTHN_MANAGE_HEADER_ACTIONS_LABEL') ?></th>
</tr>
</thead>
<tbody>
<?php // phpcs:ignore
foreach ($credentials as $method): ?>
<tr data-credential_id="<?php echo $method['id'] ?>">
<td><?php echo htmlentities($method['label']) ?></td>
<td>
<button onclick="return plgSystemWebauthnEditLabel(this, '<?php echo $randomId ?>');"
class="btn btn-secondary">
<span class="icon-edit icon-white" aria-hidden="true"></span>
<?php echo Text::_('PLG_SYSTEM_WEBAUTHN_MANAGE_BTN_EDIT_LABEL') ?>
</button>
<button onclick="return plgSystemWebauthnDelete(this, '<?php echo $randomId ?>');"
class="btn btn-danger">
<span class="icon-minus-sign icon-white" aria-hidden="true"></span>
<?php echo Text::_('PLG_SYSTEM_WEBAUTHN_MANAGE_BTN_DELETE_LABEL') ?>
</button>
</td>
</tr>
<?php endforeach; ?>
<?php // phpcs:ignore
if (empty($credentials)): ?>
<tr>
<td colspan="2">
<?php echo Text::_('PLG_SYSTEM_WEBAUTHN_MANAGE_HEADER_NOMETHODS_LABEL') ?>
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
<?php // phpcs:ignore
if ($allow_add): ?>
<p class="plg_system_webauthn-manage-add-container">
<button
type="button"
onclick="plgSystemWebauthnCreateCredentials('<?php echo $randomId ?>', '#plg_system_webauthn-management-interface'); return false;"
class="btn btn-success btn-block">
<span class="icon-plus icon-white" aria-hidden="true"></span>
<?php echo Text::_('PLG_SYSTEM_WEBAUTHN_MANAGE_BTN_ADD_LABEL') ?>
</button>
</p>
<?php endif; ?>
</div>

View File

@ -31,6 +31,22 @@ use Joomla\Registry\Registry;
*/
class AdministratorApplication extends CMSApplication
{
/**
* List of allowed components for guests and users which do not have the core.login.admin privilege.
*
* By default we allow two core components:
*
* - com_login Absolutely necessary to let users log into the backend of the site. Do NOT remove!
* - com_ajax Handle AJAX requests or other administrative callbacks without logging in. Required for
* passwordless authentication using WebAuthn.
*
* @var array
*/
protected $allowedUnprivilegedOptions = [
'com_login',
'com_ajax',
];
/**
* Class constructor.
*
@ -521,20 +537,37 @@ class AdministratorApplication extends CMSApplication
*/
public function findOption(): string
{
$app = Factory::getApplication();
/** @var self $app */
$app = Factory::getApplication();
$option = strtolower($app->input->get('option'));
$user = $app->getIdentity();
$user = $app->getIdentity();
/**
* Special handling for guest users and authenticated users without the Backend Login privilege.
*
* If the component they are trying to access is in the $this->allowedUnprivilegedOptions array we allow the
* request to go through. Otherwise we force com_login to be loaded, letting the user (re)try authenticating
* with a user account that has the Backend Login privilege.
*/
if ($user->get('guest') || !$user->authorise('core.login.admin'))
{
$option = 'com_login';
$option = in_array($option, $this->allowedUnprivilegedOptions) ? $option : 'com_login';
}
/**
* If no component is defined in the request we will try to load com_cpanel, the administrator Control Panel
* component. This allows the /administrator URL to display something meaningful after logging in instead of an
* error.
*/
if (empty($option))
{
$option = 'com_cpanel';
}
/**
* Force the option to the input object. This is necessary because we might have force-changed the component in
* the two if-blocks above.
*/
$app->input->set('option', $option);
return $option;

View File

@ -10,6 +10,8 @@ namespace Joomla\CMS\Helper;
\defined('JPATH_PLATFORM') or die;
use Exception;
use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
@ -57,4 +59,104 @@ abstract class AuthenticationHelper
return $options;
}
/**
* Get additional login buttons to add in a login module. These buttons can be used for authentication methods
* external to Joomla such as WebAuthn, login with social media providers, login with third party providers or even
* login with third party Single Sign On (SSO) services.
*
* Button definitions are returned by the onUserLoginButtons event handlers in plugins. By default, only system and
* user plugins are taken into account. The former because they are always loaded. The latter are explicitly loaded
* in this method.
*
* The onUserLoginButtons event handlers must conform to the following method definition:
*
* public function onUserLoginButtons(string $formId): array
*
* The onUserLoginButtons event handlers must return a simple array containing 0 or more button definitions.
*
* Each button definition is a hash array with the following keys:
*
* - label The translation string used as the label and title of the button
* - onclick The onclick attribute, used to fire a JavaScript event
* - id The HTML ID of the button.
* - icon [optional] A CSS class for an optional icon displayed before the label; has precedence over 'image'
* - image [optional] An image path for an optional icon displayed before the label
* - class [optional] CSS class(es) to be added to the button
*
* @param string $formId The HTML ID of the login form container.
*
* @return array Button definitions.
*
* @since 4.0.0
*/
public static function getLoginButtons(string $formId): array
{
// Get all the User plugins.
PluginHelper::importPlugin('user');
// Trigger the onUserLoginButtons event and return the button definitions.
try
{
/** @var CMSApplication $app */
$app = Factory::getApplication();
}
catch (Exception $e)
{
return [];
}
$results = $app->triggerEvent('onUserLoginButtons', [$formId]);
$buttons = [];
foreach ($results as $result)
{
// Did we get garbage back from the plugin?
if (!is_array($result) || empty($result))
{
continue;
}
// Did the developer accidentally return a single button definition instead of an array?
if (array_key_exists('label', $result))
{
$result = [$result];
}
// Process each button, making sure it conforms to the required definition
foreach ($result as $item)
{
// Force mandatory fields
$defaultButtonDefinition = [
'label' => '',
'icon' => '',
'image' => '',
'class' => '',
'id' => '',
'onclick' => '',
];
$button = array_merge($defaultButtonDefinition, $item);
// Unset anything that doesn't conform to a button definition
foreach (array_keys($button) as $key)
{
if (!in_array($key, ['label', 'icon', 'image', 'class', 'id', 'onclick']))
{
unset($button[$key]);
}
}
// We need a label and an onclick handler at the bare minimum
if (empty($button['label']) || empty($button['onclick']) || empty($button['id']))
{
continue;
}
$buttons[] = $button;
}
}
return $buttons;
}
}

View File

@ -9,17 +9,21 @@
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Helper\AuthenticationHelper;
use Joomla\CMS\Helper\ModuleHelper;
use Joomla\Module\Login\Site\Helper\LoginHelper;
$params->def('greeting', 1);
// HTML IDs
$formId = "login-form-{$module->id}";
$type = LoginHelper::getType();
$return = LoginHelper::getReturnUrl($params, $type);
$registerLink = LoginHelper::getRegistrationUrl($params);
$twofactormethods = AuthenticationHelper::getTwoFactorMethods();
$user = $app->getIdentity();
$extraButtons = AuthenticationHelper::getLoginButtons($formId);
$user = Factory::getUser();
$layout = $params->get('layout', 'default');
// Logged users must load the logout sublayout

View File

@ -104,6 +104,26 @@ Text::script('JHIDEPASSWORD');
</div>
<?php endif; ?>
<?php foreach($extraButtons as $button): ?>
<div class="mod-login__submit form-group">
<button type="button"
class="btn btn-secondary <?php echo $button['class'] ?? '' ?>"
onclick="<?php echo $button['onclick'] ?>"
title="<?php echo Text::_($button['label']) ?>"
id="<?php echo $button['id'] ?>"
>
<?php if (!empty($button['icon'])): ?>
<span class="<?php echo $button['icon'] ?>"></span>
<?php elseif (!empty($button['image'])): ?>
<?php echo HTMLHelper::_('image', $button['image'], Text::_('PLG_SYSTEM_WEBAUTHN_LOGIN_DESC'), [
'class' => 'icon',
], true) ?>
<?php endif; ?>
<?= Text::_($button['label']) ?>
</button>
</div>
<?php endforeach; ?>
<div class="mod-login__submit form-group">
<button type="submit" name="Submit" class="btn btn-primary"><?php echo Text::_('JLOGIN'); ?></button>
</div>

View File

@ -0,0 +1,488 @@
<?php
/**
* @package Joomla.Plugin
* @subpackage System.updatenotification
*
* @copyright Copyright (C) 2005 - 2019 Open Source Matters, Inc. All rights reserved.
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Plugin\System\Webauthn;
// Protect from unauthorized access
defined('_JEXEC') or die();
use Exception;
use InvalidArgumentException;
use Joomla\CMS\Encrypt\Aes;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\Database\DatabaseDriver;
use Joomla\Plugin\System\Webauthn\Helper\Joomla;
use Joomla\Registry\Registry;
use JsonException;
use RuntimeException;
use Throwable;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\PublicKeyCredentialSourceRepository;
use Webauthn\PublicKeyCredentialUserEntity;
/**
* Handles the storage of WebAuthn credentials in the database
*
* @since 4.0.0
*/
class CredentialRepository implements PublicKeyCredentialSourceRepository
{
/**
* Returns a PublicKeyCredentialSource object given the public key credential ID
*
* @param string $publicKeyCredentialId The identified of the public key credential we're searching for
*
* @return PublicKeyCredentialSource|null
*
* @since 4.0.0
*/
public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource
{
/** @var DatabaseDriver $db */
$db = Factory::getContainer()->get('DatabaseDriver');
$credentialId = base64_encode($publicKeyCredentialId);
$query = $db->getQuery(true)
->select($db->qn('credential'))
->from($db->qn('#__webauthn_credentials'))
->where($db->qn('id') . ' = :credentialId')
->bind(':credentialId', $credentialId);
$encrypted = $db->setQuery($query)->loadResult();
if (empty($encrypted))
{
return null;
}
$json = $this->decryptCredential($encrypted);
try
{
return PublicKeyCredentialSource::createFromArray(json_decode($json, true));
}
catch (Throwable $e)
{
return null;
}
}
/**
* Returns all PublicKeyCredentialSource objects given a user entity. We only use the `id` property of the user
* entity, cast to integer, as the Joomla user ID by which records are keyed in the database table.
*
* @param PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity Public key credential user entity record
*
* @return PublicKeyCredentialSource[]
*
* @since 4.0.0
*/
public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array
{
/** @var DatabaseDriver $db */
$db = Factory::getContainer()->get('DatabaseDriver');
$userHandle = $publicKeyCredentialUserEntity->getId();
$query = $db->getQuery(true)
->select('*')
->from($db->qn('#__webauthn_credentials'))
->where($db->qn('user_id') . ' = :user_id')
->bind(':user_id', $userHandle);
try
{
$records = $db->setQuery($query)->loadAssocList();
}
catch (Exception $e)
{
return [];
}
/**
* Converts invalid credential records to PublicKeyCredentialSource objects, or null if they
* are invalid.
*
* This closure is defined as a variable to prevent PHP-CS from getting a stoke trying to
* figure out the correct indentation :)
*
* @param array $record The record to convert
*
* @return PublicKeyCredentialSource|null
*/
$recordsMapperClosure = function ($record)
{
try
{
$json = $this->decryptCredential($record['credential']);
$data = json_decode($json, true);
}
catch (JsonException $e)
{
return null;
}
if (empty($data))
{
return null;
}
try
{
return PublicKeyCredentialSource::createFromArray($data);
}
catch (InvalidArgumentException $e)
{
return null;
}
};
$records = array_map($recordsMapperClosure, $records);
/**
* Filters the list of records to only keep valid entries.
*
* Only array members that are PublicKeyCredentialSource objects survive the filter.
*
* This closure is defined as a variable to prevent PHP-CS from getting a stoke trying to
* figure out the correct indentation :)
*
* @param PublicKeyCredentialSource|mixed $record The record to filter
*
* @return boolean
*/
$filterClosure = function ($record)
{
return !is_null($record) && is_object($record) && ($record instanceof PublicKeyCredentialSource);
};
return array_filter($records, $filterClosure);
}
/**
* Add or update an attested credential for a given user.
*
* @param PublicKeyCredentialSource $publicKeyCredentialSource The public key credential
* source to store
*
* @return void
*
* @throws Exception
* @since 4.0.0
*/
public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void
{
// Default values for saving a new credential source
$credentialId = base64_encode($publicKeyCredentialSource->getPublicKeyCredentialId());
$user = Factory::getApplication()->getIdentity();
$o = (object) [
'id' => $credentialId,
'user_id' => $this->getHandleFromUserId($user->id),
'label' => Text::sprintf('PLG_SYSTEM_WEBAUTHN_LBL_DEFAULT_AUTHENTICATOR_LABEL', Joomla::formatDate('now')),
'credential' => json_encode($publicKeyCredentialSource),
];
$update = false;
/** @var DatabaseDriver $db */
$db = Factory::getContainer()->get('DatabaseDriver');
// Try to find an existing record
try
{
$query = $db->getQuery(true)
->select('*')
->from($db->qn('#__webauthn_credentials'))
->where($db->qn('id') . ' = :credentialId')
->bind(':credentialId', $credentialId);
$oldRecord = $db->setQuery($query)->loadObject();
if (is_null($oldRecord))
{
throw new Exception('This is a new record');
}
/**
* Sanity check. The existing credential source must have the same user handle as the one I am trying to
* save. Otherwise something fishy is going on.
*/
// phpcs:ignore
if ($oldRecord->user_id != $publicKeyCredentialSource->getUserHandle())
{
throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREDENTIAL_ID_ALREADY_IN_USE'));
}
// phpcs:ignore
$o->user_id = $oldRecord->user_id;
$o->label = $oldRecord->label;
$update = true;
}
catch (Exception $e)
{
}
$o->credential = $this->encryptCredential($o->credential);
if ($update)
{
$db->updateObject('#__webauthn_credentials', $o, ['id']);
return;
}
/**
* This check is deliberately skipped for updates. When logging in the underlying library will try to save the
* credential source. This is necessary to update the last known authenticator signature counter which prevents
* replay attacks. When we are saving a new record, though, we have to make sure we are not a guest user. Hence
* the check below.
*/
if ((is_null($user) || $user->guest))
{
throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CANT_STORE_FOR_GUEST'));
}
$db->insertObject('#__webauthn_credentials', $o);
}
/**
* Get all credential information for a given user ID. This is meant to only be used for displaying records.
*
* @param int $userId The user ID
*
* @return array
*
* @since 4.0.0
*/
public function getAll(int $userId): array
{
/** @var DatabaseDriver $db */
$db = Factory::getContainer()->get('DatabaseDriver');
$userHandle = $this->getHandleFromUserId($userId);
$query = $db->getQuery(true)
->select('*')
->from($db->qn('#__webauthn_credentials'))
->where($db->qn('user_id') . ' = :user_id')
->bind(':user_id', $userHandle);
try
{
$results = $db->setQuery($query)->loadAssocList();
}
catch (Exception $e)
{
return [];
}
if (empty($results))
{
return [];
}
return $results;
}
/**
* Do we have stored credentials under the specified Credential ID?
*
* @param string $credentialId The ID of the credential to check for existence
*
* @return boolean
*
* @since 4.0.0
*/
public function has(string $credentialId): bool
{
/** @var DatabaseDriver $db */
$db = Factory::getContainer()->get('DatabaseDriver');
$credentialId = base64_encode($credentialId);
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->qn('#__webauthn_credentials'))
->where($db->qn('id') . ' = :credentialId')
->bind(':credentialId', $credentialId);
try
{
$count = $db->setQuery($query)->loadResult();
return $count > 0;
}
catch (Exception $e)
{
return false;
}
}
/**
* Update the human readable label of a credential
*
* @param string $credentialId The credential ID
* @param string $label The human readable label to set
*
* @return void
*
* @since 4.0.0
*/
public function setLabel(string $credentialId, string $label): void
{
/** @var DatabaseDriver $db */
$db = Factory::getContainer()->get('DatabaseDriver');
$credentialId = base64_encode($credentialId);
$o = (object) [
'id' => $credentialId,
'label' => $label,
];
$db->updateObject('#__webauthn_credentials', $o, ['id'], false);
}
/**
* Remove stored credentials
*
* @param string $credentialId The credentials ID to remove
*
* @return void
*
* @since 4.0.0
*/
public function remove(string $credentialId): void
{
if (!$this->has($credentialId))
{
return;
}
/** @var DatabaseDriver $db */
$db = Factory::getContainer()->get('DatabaseDriver');
$credentialId = base64_encode($credentialId);
$query = $db->getQuery(true)
->delete($db->qn('#__webauthn_credentials'))
->where($db->qn('id') . ' = :credentialId')
->bind(':credentialId', $credentialId);
$db->setQuery($query)->execute();
}
/**
* Return the user handle for the stored credential given its ID.
*
* The user handle must not be personally identifiable. Per https://w3c.github.io/webauthn/#user-handle it is
* acceptable to have a salted hash with a salt private to our server, e.g. Joomla's secret. The only immutable
* information in Joomla is the user ID so that's what we will be using.
*
* @param string $credentialId The credential ID to get the user handle for
*
* @return string
*
* @since 4.0.0
*/
public function getUserHandleFor(string $credentialId): string
{
$publicKeyCredentialSource = $this->findOneByCredentialId($credentialId);
if (empty($publicKeyCredentialSource))
{
return '';
}
return $publicKeyCredentialSource->getUserHandle();
}
/**
* Return a user handle given an integer Joomla user ID. We use the HMAC-SHA-256 of the user ID with the site's
* secret as the key. Using it instead of SHA-512 is on purpose! WebAuthn only allows user handles up to 64 bytes
* long.
*
* @param int $id The user ID to convert
*
* @return string The user handle (HMAC-SHA-256 of the user ID)
*
* @since 4.0.0
*/
public function getHandleFromUserId(int $id): string
{
$key = $this->getEncryptionKey();
$data = sprintf('%010u', $id);
return hash_hmac('sha256', $data, $key, false);
}
/**
* Encrypt the credential source before saving it to the database
*
* @param string $credential The unencrypted, JSON-encoded credential source
*
* @return string The encrypted credential source, base64 encoded
*
* @since 4.0.0
*/
private function encryptCredential(string $credential): string
{
$key = $this->getEncryptionKey();
if (empty($key))
{
return $credential;
}
$aes = new Aes($key, 256);
return $aes->encryptString($credential);
}
/**
* Decrypt the credential source if it was already encrypted in the database
*
* @param string $credential The encrypted credential source, base64 encoded
*
* @return string The decrypted, JSON-encoded credential source
*
* @since 4.0.0
*/
private function decryptCredential(string $credential): string
{
$key = $this->getEncryptionKey();
if (empty($key))
{
return $credential;
}
// Was the credential stored unencrypted (e.g. the site's secret was empty)?
if ((strpos($credential, '{') !== false) && (strpos($credential, '"publicKeyCredentialId"') !== false))
{
return $credential;
}
$aes = new Aes($key, 256);
return $aes->decryptString($credential);
}
/**
* Get the site's secret, used as an encryption key
*
* @return string
*
* @since 4.0.0
*/
private function getEncryptionKey(): string
{
try
{
$app = Factory::getApplication();
/** @var Registry $config */
$config = $app->getConfig();
$secret = $config->get('secret', '');
}
catch (Exception $e)
{
$secret = '';
}
return $secret;
}
}

View File

@ -0,0 +1,25 @@
<?php
/**
* @package Joomla.Plugin
* @subpackage System.updatenotification
*
* @copyright Copyright (C) 2005 - 2019 Open Source Matters, Inc. All rights reserved.
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Plugin\System\Webauthn\Exception;
// Protect from unauthorized access
defined('_JEXEC') or die();
use RuntimeException;
/**
* Exception indicating that the Joomla application object is not a CMSApplication subclass.
*
* @since 4.0.0
*/
class AjaxNonCmsAppException extends RuntimeException
{
}

View File

@ -0,0 +1,377 @@
<?php
/**
* @package Joomla.Plugin
* @subpackage System.updatenotification
*
* @copyright Copyright (C) 2005 - 2019 Open Source Matters, Inc. All rights reserved.
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Plugin\System\Webauthn\Helper;
// Protect from unauthorized access
defined('_JEXEC') or die();
use CBOR\Decoder;
use CBOR\OtherObject\OtherObjectManager;
use CBOR\Tag\TagObjectManager;
use Cose\Algorithm\Manager;
use Cose\Algorithm\Signature\ECDSA;
use Cose\Algorithm\Signature\EdDSA;
use Cose\Algorithm\Signature\RSA;
use Cose\Algorithms;
use Exception;
use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Crypt\Crypt;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\User\User;
use Joomla\CMS\User\UserFactoryInterface;
use Joomla\Plugin\System\Webauthn\CredentialRepository;
use Laminas\Diactoros\ServerRequestFactory;
use RuntimeException;
use Webauthn\AttestationStatement\AndroidKeyAttestationStatementSupport;
use Webauthn\AttestationStatement\AttestationObjectLoader;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AttestationStatement\FidoU2FAttestationStatementSupport;
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
use Webauthn\AttestationStatement\PackedAttestationStatementSupport;
use Webauthn\AttestationStatement\TPMAttestationStatementSupport;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs;
use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
use Webauthn\AuthenticatorAttestationResponse;
use Webauthn\AuthenticatorAttestationResponseValidator;
use Webauthn\AuthenticatorSelectionCriteria;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialDescriptor;
use Webauthn\PublicKeyCredentialLoader;
use Webauthn\PublicKeyCredentialParameters;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\PublicKeyCredentialUserEntity;
use Webauthn\TokenBinding\TokenBindingNotSupportedHandler;
/**
* Helper class to aid in credentials creation (link an authenticator to a user account)
*
* @since 4.0.0
*/
abstract class CredentialsCreation
{
/**
* Create a public key for credentials creation. The result is a JSON string which can be used in Javascript code
* with navigator.credentials.create().
*
* @param User $user The Joomla user to create the public key for
*
* @return string
*
* @since 4.0.0
*/
public static function createPublicKey(User $user): string
{
/** @var CMSApplication $app */
try
{
$app = Factory::getApplication();
$siteName = $app->getConfig()->get('sitename');
}
catch (Exception $e)
{
$siteName = 'Joomla! Site';
}
// Credentials repository
$repository = new CredentialRepository;
// Relaying Party -- Our site
$rpEntity = new PublicKeyCredentialRpEntity(
$siteName,
Uri::getInstance()->toString(['host']),
self::getSiteIcon()
);
// User Entity
$userEntity = new PublicKeyCredentialUserEntity(
$user->username,
$repository->getHandleFromUserId($user->id),
$user->name,
self::getAvatar($user, 64)
);
// Challenge
try
{
$challenge = random_bytes(32);
}
catch (Exception $e)
{
$challenge = Crypt::genRandomBytes(32);
}
// Public Key Credential Parameters
$publicKeyCredentialParametersList = [
new PublicKeyCredentialParameters('public-key', Algorithms::COSE_ALGORITHM_ES256),
];
// Timeout: 60 seconds (given in milliseconds)
$timeout = 60000;
// Devices to exclude (already set up authenticators)
$excludedPublicKeyDescriptors = [];
$records = $repository->findAllForUserEntity($userEntity);
/** @var PublicKeyCredentialSource $record */
foreach ($records as $record)
{
$excludedPublicKeyDescriptors[] = new PublicKeyCredentialDescriptor($record->getType(), $record->getCredentialPublicKey());
}
// Authenticator Selection Criteria (we used default values)
$authenticatorSelectionCriteria = new AuthenticatorSelectionCriteria;
// Extensions (not yet supported by the library)
$extensions = new AuthenticationExtensionsClientInputs;
// Attestation preference
$attestationPreference = PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE;
// Public key credential creation options
$publicKeyCredentialCreationOptions = new PublicKeyCredentialCreationOptions(
$rpEntity,
$userEntity,
$challenge,
$publicKeyCredentialParametersList,
$timeout,
$excludedPublicKeyDescriptors,
$authenticatorSelectionCriteria,
$attestationPreference,
$extensions
);
// Save data in the session
Joomla::setSessionVar('publicKeyCredentialCreationOptions',
base64_encode(serialize($publicKeyCredentialCreationOptions)),
'plg_system_webauthn'
);
Joomla::setSessionVar('registration_user_id', $user->id, 'plg_system_webauthn');
return json_encode($publicKeyCredentialCreationOptions, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
/**
* Validate the authentication data returned by the device and return the public key credential source on success.
*
* An exception will be returned on error. Also, under very rare conditions, you may receive NULL instead of
* a PublicKeyCredentialSource object which means that something was off in the returned data from the browser.
*
* @param string $data The JSON-encoded data returned by the browser during the authentication flow
*
* @return PublicKeyCredentialSource|null
*
* @since 4.0.0
*/
public static function validateAuthenticationData(string $data): ?PublicKeyCredentialSource
{
// Retrieve the PublicKeyCredentialCreationOptions object created earlier and perform sanity checks
$encodedOptions = Joomla::getSessionVar('publicKeyCredentialCreationOptions', null, 'plg_system_webauthn');
if (empty($encodedOptions))
{
throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_NO_PK'));
}
try
{
$publicKeyCredentialCreationOptions = unserialize(base64_decode($encodedOptions));
}
catch (Exception $e)
{
$publicKeyCredentialCreationOptions = null;
}
if (!is_object($publicKeyCredentialCreationOptions) || !($publicKeyCredentialCreationOptions instanceof PublicKeyCredentialCreationOptions))
{
throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_NO_PK'));
}
// Retrieve the stored user ID and make sure it's the same one in the request.
$storedUserId = Joomla::getSessionVar('registration_user_id', 0, 'plg_system_webauthn');
try
{
$myUser = Factory::getApplication()->getIdentity();
}
catch (Exception $e)
{
$dummyUserId = 0;
$myUser = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($dummyUserId);
}
$myUserId = $myUser->id;
if (($myUser->guest) || ($myUserId != $storedUserId))
{
throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_USER'));
}
// Cose Algorithm Manager
$coseAlgorithmManager = new Manager;
$coseAlgorithmManager->add(new ECDSA\ES256);
$coseAlgorithmManager->add(new ECDSA\ES512);
$coseAlgorithmManager->add(new EdDSA\EdDSA);
$coseAlgorithmManager->add(new RSA\RS1);
$coseAlgorithmManager->add(new RSA\RS256);
$coseAlgorithmManager->add(new RSA\RS512);
// Create a CBOR Decoder object
$otherObjectManager = new OtherObjectManager;
$tagObjectManager = new TagObjectManager;
$decoder = new Decoder($tagObjectManager, $otherObjectManager);
// The token binding handler
$tokenBindingHandler = new TokenBindingNotSupportedHandler;
// Attestation Statement Support Manager
$attestationStatementSupportManager = new AttestationStatementSupportManager;
$attestationStatementSupportManager->add(new NoneAttestationStatementSupport);
$attestationStatementSupportManager->add(new FidoU2FAttestationStatementSupport($decoder));
/**
$attestationStatementSupportManager->add(
new AndroidSafetyNetAttestationStatementSupport(HttpFactory::getHttp(),
'GOOGLE_SAFETYNET_API_KEY',
new RequestFactory
)
);
*/
$attestationStatementSupportManager->add(new AndroidKeyAttestationStatementSupport($decoder));
$attestationStatementSupportManager->add(new TPMAttestationStatementSupport);
$attestationStatementSupportManager->add(new PackedAttestationStatementSupport($decoder, $coseAlgorithmManager));
// Attestation Object Loader
$attestationObjectLoader = new AttestationObjectLoader($attestationStatementSupportManager, $decoder);
// Public Key Credential Loader
$publicKeyCredentialLoader = new PublicKeyCredentialLoader($attestationObjectLoader, $decoder);
// Credential Repository
$credentialRepository = new CredentialRepository;
// Extension output checker handler
$extensionOutputCheckerHandler = new ExtensionOutputCheckerHandler;
// Authenticator Attestation Response Validator
$authenticatorAttestationResponseValidator = new AuthenticatorAttestationResponseValidator(
$attestationStatementSupportManager,
$credentialRepository,
$tokenBindingHandler,
$extensionOutputCheckerHandler
);
// Any Throwable from this point will bubble up to the GUI
// We init the PSR-7 request object using Diactoros
$request = ServerRequestFactory::fromGlobals();
// Load the data
$publicKeyCredential = $publicKeyCredentialLoader->load(base64_decode($data));
$response = $publicKeyCredential->getResponse();
// Check if the response is an Authenticator Attestation Response
if (!$response instanceof AuthenticatorAttestationResponse)
{
throw new RuntimeException('Not an authenticator attestation response');
}
// Check the response against the request
$authenticatorAttestationResponseValidator->check($response, $publicKeyCredentialCreationOptions, $request);
/**
* Everything is OK here. You can get the Public Key Credential Source. This object should be persisted using
* the Public Key Credential Source repository.
*/
return PublicKeyCredentialSource::createFromPublicKeyCredential(
$publicKeyCredential,
$publicKeyCredentialCreationOptions->getUser()->getId()
);
}
/**
* Try to find the site's favicon in the site's root, images, media, templates or current template directory.
*
* @return string|null
*
* @since 4.0.0
*/
protected static function getSiteIcon(): ?string
{
$filenames = [
'apple-touch-icon.png',
'apple_touch_icon.png',
'favicon.ico',
'favicon.png',
'favicon.gif',
'favicon.bmp',
'favicon.jpg',
'favicon.svg',
];
try
{
$paths = [
'/',
'/images/',
'/media/',
'/templates/',
'/templates/' . Factory::getApplication()->getTemplate(),
];
}
catch (Exception $e)
{
return null;
}
foreach ($paths as $path)
{
foreach ($filenames as $filename)
{
$relFile = $path . $filename;
$filePath = JPATH_BASE . $relFile;
if (is_file($filePath))
{
break 2;
}
$relFile = null;
}
}
if (!isset($relFile) || is_null($relFile))
{
return null;
}
return rtrim(Uri::base(), '/') . '/' . ltrim($relFile, '/');
}
/**
* Get the user's avatar (through Gravatar)
*
* @param User $user The Joomla user object
* @param int $size The dimensions of the image to fetch (default: 64 pixels)
*
* @return string The URL to the user's avatar
*
* @since 4.0.0
*/
public static function getAvatar(User $user, int $size = 64)
{
$scheme = Uri::getInstance()->getScheme();
$subdomain = ($scheme == 'https') ? 'secure' : 'www';
return sprintf('%s://%s.gravatar.com/avatar/%s.jpg?s=%u&d=mm', $scheme, $subdomain, md5($user->email), $size);
}
}

View File

@ -0,0 +1,744 @@
<?php
/**
* @package Joomla.Plugin
* @subpackage System.updatenotification
*
* @copyright Copyright (C) 2005 - 2019 Open Source Matters, Inc. All rights reserved.
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Plugin\System\Webauthn\Helper;
// Protect from unauthorized access
defined('_JEXEC') or die();
use DateTime;
use DateTimeZone;
use Exception;
use JLoader;
use Joomla\Application\AbstractApplication;
use Joomla\CMS\Application\CliApplication;
use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Application\ConsoleApplication;
use Joomla\CMS\Authentication\Authentication;
use Joomla\CMS\Authentication\AuthenticationResponse;
use Joomla\CMS\Date\Date;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Layout\FileLayout;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\CMS\User\User;
use Joomla\CMS\User\UserFactoryInterface;
use Joomla\CMS\User\UserHelper;
use Joomla\Registry\Registry;
use RuntimeException;
/**
* A helper class for abstracting core features in Joomla! 3.4 and later, including 4.x
*
* @since 4.0.0
*/
abstract class Joomla
{
/**
* A fake session storage for CLI apps. Since CLI applications cannot have a session we are
* using a Registry object we manage internally.
*
* @var Registry
* @since 4.0.0
*/
protected static $fakeSession = null;
/**
* Are we inside the administrator application
*
* @var boolean
* @since 4.0.0
*/
protected static $isAdmin = null;
/**
* Are we inside a CLI application
*
* @var boolean
* @since 4.0.0
*/
protected static $isCli = null;
/**
* Which plugins have already registered a text file logger. Prevents double registration of a
* log file.
*
* @var array
* @since 4.0.0
*/
protected static $registeredLoggers = [];
/**
* The current Joomla Document type
*
* @var string|null
* @since 4.0.0
*/
protected static $joomlaDocumentType = null;
/**
* Is the current user allowed to edit the social login configuration of $user? To do so I must
* either be editing my own account OR I have to be a Super User.
*
* @param User $user The user you want to know if we're allowed to edit
*
* @return boolean
*
* @since 4.0.0
*/
public static function canEditUser(User $user = null): bool
{
// I can edit myself
if (empty($user))
{
return true;
}
// Guests can't have social logins associated
if ($user->guest)
{
return false;
}
// Get the currently logged in used
try
{
$myUser = Factory::getApplication()->getIdentity();
}
catch (Exception $e)
{
// Cannot get the application; no user, therefore no edit privileges.
return false;
}
// Same user? I can edit myself
if ($myUser->id == $user->id)
{
return true;
}
// To edit a different user I must be a Super User myself. If I'm not, I can't edit another user!
if (!$myUser->authorise('core.admin'))
{
return false;
}
// I am a Super User editing another user. That's allowed.
return true;
}
/**
* Helper method to render a JLayout.
*
* @param string $layoutFile Dot separated path to the layout file, relative to base path
* (plugins/system/webauthn/layout)
* @param object $displayData Object which properties are used inside the layout file to
* build displayed output
* @param string $includePath Additional path holding layout files
* @param mixed $options Optional custom options to load. Registry or array format.
* Set 'debug'=>true to output debug information.
*
* @return string
*
* @since 4.0.0
*/
public static function renderLayout(string $layoutFile, $displayData = null,
string $includePath = '', array $options = []
): string
{
$basePath = JPATH_SITE . '/plugins/system/webauthn/layout';
$layout = new FileLayout($layoutFile, $basePath, $options);
if (!empty($includePath))
{
$layout->addIncludePath($includePath);
}
return $layout->render($displayData);
}
/**
* Unset a variable from the user session
*
* This method cannot be replaced with a call to Factory::getSession->set(). This method takes
* into account running under CLI, using a fake session storage. In the end of the day this
* plugin doesn't work under CLI but being able to fake session storage under CLI means that we
* don't have to add gnarly if-blocks everywhere in the code to make sure it doesn't break CLI
* either!
*
* @param string $name The name of the variable to unset
* @param string $namespace (optional) The variable's namespace e.g. the component name.
* Default: 'default'
*
* @return void
*
* @since 4.0.0
*/
public static function unsetSessionVar(string $name, string $namespace = 'default'): void
{
self::setSessionVar($name, null, $namespace);
}
/**
* Set a variable in the user session.
*
* This method cannot be replaced with a call to Factory::getSession->set(). This method takes
* into account running under CLI, using a fake session storage. In the end of the day this
* plugin doesn't work under CLI but being able to fake session storage under CLI means that we
* don't have to add gnarly if-blocks everywhere in the code to make sure it doesn't break CLI
* either!
*
* @param string $name The name of the variable to set
* @param string $value (optional) The value to set it to, default is null
* @param string $namespace (optional) The variable's namespace e.g. the component name.
* Default: 'default'
*
* @return void
*
* @since 4.0.0
*/
public static function setSessionVar(string $name, ?string $value = null,
string $namespace = 'default'
): void
{
$qualifiedKey = "$namespace.$name";
if (self::isCli())
{
self::getFakeSession()->set($qualifiedKey, $value);
return;
}
try
{
Factory::getApplication()->getSession()->set($qualifiedKey, $value);
}
catch (Exception $e)
{
return;
}
}
/**
* Are we inside a CLI application
*
* @param CMSApplication $app The current CMS application which tells us if we are inside
* an admin page
*
* @return boolean
*
* @since 4.0.0
*/
public static function isCli(CMSApplication $app = null): bool
{
if (is_null(self::$isCli))
{
if (is_null($app))
{
try
{
$app = Factory::getApplication();
}
catch (Exception $e)
{
$app = null;
}
}
if (is_null($app))
{
self::$isCli = true;
}
if (is_object($app))
{
self::$isCli = $app instanceof Exception;
if (class_exists('Joomla\\CMS\\Application\\CliApplication'))
{
self::$isCli = self::$isCli || $app instanceof CliApplication || $app instanceof ConsoleApplication;
}
}
}
return self::$isCli;
}
/**
* Get a fake session registry for CLI applications
*
* @return Registry
*
* @since 4.0.0
*/
protected static function getFakeSession(): Registry
{
if (!is_object(self::$fakeSession))
{
self::$fakeSession = new Registry;
}
return self::$fakeSession;
}
/**
* Return the session token. This method goes through our session abstraction to prevent a
* fatal exception if it's accidentally called under CLI.
*
* @return mixed
*
* @since 4.0.0
*/
public static function getToken(): string
{
// For CLI apps we implement our own fake token system
if (self::isCli())
{
$token = self::getSessionVar('session.token');
// Create a token
if (is_null($token))
{
$token = UserHelper::genRandomPassword(32);
self::setSessionVar('session.token', $token);
}
return (string) $token;
}
// Web application, go through the regular Joomla! API.
try
{
return Factory::getApplication()->getSession()->getToken();
}
catch (Exception $e)
{
return '';
}
}
/**
* Get a variable from the user session
*
* This method cannot be replaced with a call to Factory::getSession->get(). This method takes
* into account running under CLI, using a fake session storage. In the end of the day this
* plugin doesn't work under CLI but being able to fake session storage under CLI means that we
* don't have to add gnarly if-blocks everywhere in the code to make sure it doesn't break CLI
* either!
*
* @param string $name The name of the variable to set
* @param string $default (optional) The default value to return if the variable does not
* exit, default: null
* @param string $namespace (optional) The variable's namespace e.g. the component name.
* Default: 'default'
*
* @return mixed
*
* @since 4.0.0
*/
public static function getSessionVar(string $name, ?string $default = null,
string $namespace = 'default'
)
{
$qualifiedKey = "$namespace.$name";
if (self::isCli())
{
return self::getFakeSession()->get("$namespace.$name", $default);
}
try
{
return Factory::getApplication()->getSession()->get($qualifiedKey, $default);
}
catch (Exception $e)
{
return $default;
}
}
/**
* Register a debug log file writer for a Social Login plugin.
*
* @param string $plugin The Social Login plugin for which to register a debug log file
* writer
*
* @return void
*
* @since 4.0.0
*/
public static function addLogger(string $plugin): void
{
// Make sure this logger is not already registered
if (in_array($plugin, self::$registeredLoggers))
{
return;
}
self::$registeredLoggers[] = $plugin;
// We only log errors unless Site Debug is enabled
$logLevels = Log::ERROR | Log::CRITICAL | Log::ALERT | Log::EMERGENCY;
if (defined('JDEBUG') && JDEBUG)
{
$logLevels = Log::ALL;
}
// Add a formatted text logger
Log::addLogger([
'text_file' => "webauthn_{$plugin}.php",
'text_entry_format' => '{DATETIME} {PRIORITY} {CLIENTIP} {MESSAGE}'
], $logLevels, [
"webauthn.{$plugin}"
]
);
}
/**
* Logs in a user to the site, bypassing the authentication plugins.
*
* @param int $userId The user ID to log in
* @param AbstractApplication $app The application we are running in. Skip to
* auto-detect (recommended).
*
* @return void
*
* @throws Exception
*
* @since 4.0.0
*/
public static function loginUser(int $userId, AbstractApplication $app = null): void
{
// Trick the class auto-loader into loading the necessary classes
class_exists('Joomla\\CMS\\Authentication\\Authentication', true);
// Fake a successful login message
if (!is_object($app))
{
$app = Factory::getApplication();
}
$isAdmin = $app->isClient('administrator');
/** @var User $user */
$user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);
// Does the user account have a pending activation?
if (!empty($user->activation))
{
throw new RuntimeException(Text::_('JGLOBAL_AUTH_ACCESS_DENIED'));
}
// Is the user account blocked?
if ($user->block)
{
throw new RuntimeException(Text::_('JGLOBAL_AUTH_ACCESS_DENIED'));
}
$statusSuccess = Authentication::STATUS_SUCCESS;
$response = self::getAuthenticationResponseObject();
$response->status = $statusSuccess;
$response->username = $user->username;
$response->fullname = $user->name;
// phpcs:ignore
$response->error_message = '';
$response->language = $user->getParam('language');
$response->type = 'Passwordless';
if ($isAdmin)
{
$response->language = $user->getParam('admin_language');
}
/**
* Set up the login options.
*
* The 'remember' element forces the use of the Remember Me feature when logging in with Webauthn, as the
* users would expect.
*
* The 'action' element is actually required by plg_user_joomla. It is the core ACL action the logged in user
* must be allowed for the login to succeed. Please note that front-end and back-end logins use a different
* action. This allows us to provide the social login button on both front- and back-end and be sure that if a
* used with no backend access tries to use it to log in Joomla! will just slap him with an error message about
* insufficient privileges - the same thing that'd happen if you tried to use your front-end only username and
* password in a back-end login form.
*/
$options = [
'remember' => true,
'action' => 'core.login.site',
];
if (self::isAdminPage())
{
$options['action'] = 'core.login.admin';
}
// Run the user plugins. They CAN block login by returning boolean false and setting $response->error_message.
PluginHelper::importPlugin('user');
/** @var CMSApplication $app */
$results = $app->triggerEvent('onUserLogin', [(array) $response, $options]);
// If there is no boolean FALSE result from any plugin the login is successful.
if (in_array(false, $results, true) == false)
{
// Set the user in the session, letting Joomla! know that we are logged in.
$app->getSession()->set('user', $user);
// Trigger the onUserAfterLogin event
$options['user'] = $user;
$options['responseType'] = $response->type;
// The user is successfully logged in. Run the after login events
$app->triggerEvent('onUserAfterLogin', [$options]);
return;
}
// If we are here the plugins marked a login failure. Trigger the onUserLoginFailure Event.
$app->triggerEvent('onUserLoginFailure', [(array) $response]);
// Log the failure
// phpcs:ignore
Log::add($response->error_message, Log::WARNING, 'jerror');
// Throw an exception to let the caller know that the login failed
// phpcs:ignore
throw new RuntimeException($response->error_message);
}
/**
* Returns a (blank) Joomla! authentication response
*
* @return AuthenticationResponse
*
* @since 4.0.0
*/
public static function getAuthenticationResponseObject(): AuthenticationResponse
{
// Force the class auto-loader to load the JAuthentication class
JLoader::import('joomla.user.authentication');
class_exists('Joomla\\CMS\\Authentication\\Authentication', true);
return new AuthenticationResponse;
}
/**
* Are we inside an administrator page?
*
* @param CMSApplication $app The current CMS application which tells us if we are inside
* an admin page
*
* @return boolean
*
* @throws Exception
*
* @since 4.0.0
*/
public static function isAdminPage(CMSApplication $app = null): bool
{
if (is_null(self::$isAdmin))
{
if (is_null($app))
{
$app = Factory::getApplication();
}
self::$isAdmin = $app->isClient('administrator');
}
return self::$isAdmin;
}
/**
* Have Joomla! process a login failure
*
* @param AuthenticationResponse $response The Joomla! auth response object
* @param AbstractApplication $app The application we are running in. Skip to
* auto-detect (recommended).
* @param string $logContext Logging context (plugin name). Default:
* system.
*
* @return boolean
*
* @throws Exception
*
* @since 4.0.0
*/
public static function processLoginFailure(AuthenticationResponse $response,
AbstractApplication $app = null,
string $logContext = 'system'
)
{
// Import the user plugin group.
PluginHelper::importPlugin('user');
if (!is_object($app))
{
$app = Factory::getApplication();
}
// Trigger onUserLoginFailure Event.
self::log($logContext, "Calling onUserLoginFailure plugin event");
/** @var CMSApplication $app */
$app->triggerEvent('onUserLoginFailure', [(array) $response]);
// If status is success, any error will have been raised by the user plugin
$expectedStatus = Authentication::STATUS_SUCCESS;
if ($response->status !== $expectedStatus)
{
self::log($logContext, "The login failure has been logged in Joomla's error log");
// Everything logged in the 'jerror' category ends up being enqueued in the application message queue.
// phpcs:ignore
Log::add($response->error_message, Log::WARNING, 'jerror');
}
else
{
$message = "The login failure was caused by a third party user plugin but it did not " .
"return any further information. Good luck figuring this one out...";
self::log($logContext, $message, Log::WARNING);
}
return false;
}
/**
* Writes a log message to the debug log
*
* @param string $plugin The Social Login plugin which generated this log message
* @param string $message The message to write to the log
* @param int $priority Log message priority, default is Log::DEBUG
*
* @return void
*
* @since 4.0.0
*/
public static function log(string $plugin, string $message, $priority = Log::DEBUG): void
{
Log::add($message, $priority, 'webauthn.' . $plugin);
}
/**
* Format a date for display.
*
* The $tzAware parameter defines whether the formatted date will be timezone-aware. If set to
* false the formatted date will be rendered in the UTC timezone. If set to true the code will
* automatically try to use the logged in user's timezone or, if none is set, the site's
* default timezone (Server Timezone). If set to a positive integer the same thing will happen
* but for the specified user ID instead of the currently logged in user.
*
* @param string|DateTime $date The date to format
* @param string $format The format string, default is Joomla's DATE_FORMAT_LC6
* (usually "Y-m-d H:i:s")
* @param bool|int $tzAware Should the format be timezone aware? See notes above.
*
* @return string
*
* @since 4.0.0
*/
public static function formatDate($date, ?string $format = null, bool $tzAware = true): string
{
$utcTimeZone = new DateTimeZone('UTC');
$jDate = new Date($date, $utcTimeZone);
// Which timezone should I use?
$tz = null;
if ($tzAware !== false)
{
$userId = is_bool($tzAware) ? null : (int) $tzAware;
try
{
/** @var CMSApplication $app */
$app = Factory::getApplication();
$tzDefault = $app->get('offset');
}
catch (Exception $e)
{
$tzDefault = 'GMT';
}
/** @var User $user */
if (empty($userId))
{
$user = $app->getIdentity();
}
else
{
$user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);
}
$tz = $user->getParam('timezone', $tzDefault);
}
if (!empty($tz))
{
try
{
$userTimeZone = new DateTimeZone($tz);
$jDate->setTimezone($userTimeZone);
}
catch (Exception $e)
{
// Nothing. Fall back to UTC.
}
}
if (empty($format))
{
$format = Text::_('DATE_FORMAT_LC6');
}
return $jDate->format($format, true);
}
/**
* Returns the current Joomla document type.
*
* The error catching is necessary because the application document object or even the
* application object itself may have not yet been initialized. For example, a system plugin
* running inside a custom application object which does not create a document object or which
* does not go through Joomla's Factory to create the application object. In practice these are
* CLI and custom web applications used for maintenance and third party service callbacks. They
* end up loading the system plugins but either don't go through Factory or at least don't
* create a document object.
*
* @return string
*
* @since 4.0.0
*/
public static function getDocumentType(): string
{
if (is_null(self::$joomlaDocumentType))
{
try
{
/** @var CMSApplication $app */
$app = Factory::getApplication();
$document = $app->getDocument();
}
catch (Exception $e)
{
$document = null;
}
self::$joomlaDocumentType = (is_null($document)) ? 'error' : $document->getType();
}
return self::$joomlaDocumentType;
}
}

View File

@ -0,0 +1,199 @@
<?php
/**
* @package Joomla.Plugin
* @subpackage System.updatenotification
*
* @copyright Copyright (C) 2005 - 2019 Open Source Matters, Inc. All rights reserved.
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Plugin\System\Webauthn\PluginTraits;
// Protect from unauthorized access
defined('_JEXEC') or die();
use Exception;
use Joomla\CMS\Factory;
use Joomla\CMS\Helper\AuthenticationHelper;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\User\UserHelper;
use Joomla\Plugin\System\Webauthn\Helper\Joomla;
/**
* Inserts Webauthn buttons into login modules
*
* @since 4.0.0
*/
trait AdditionalLoginButtons
{
/**
* Do I need to I inject buttons? Automatically detected (i.e. disabled if I'm already logged in).
*
* @var boolean|null
* @since 4.0.0
*/
protected $allowButtonDisplay = null;
/**
* Have I already injected CSS and JavaScript? Prevents double inclusion of the same files.
*
* @var boolean
* @since 4.0.0
*/
private $injectedCSSandJS = false;
/**
* Should I allow this plugin to add a WebAuthn login button?
*
* @return boolean
*
* @since 4.0.0
*/
private function mustDisplayButton(): bool
{
if (is_null($this->allowButtonDisplay))
{
$this->allowButtonDisplay = false;
/**
* Do not add a WebAuthn login button if we are already logged in
*/
try
{
if (!Factory::getApplication()->getIdentity()->guest)
{
return false;
}
}
catch (Exception $e)
{
return false;
}
/**
* Don't try to show a button if we can't figure out if this is a front- or backend page (it's probably a
* CLI or custom application).
*/
try
{
Joomla::isAdminPage();
}
catch (Exception $e)
{
return false;
}
/**
* Only display a button on HTML output
*/
if (Joomla::getDocumentType() != 'html')
{
return false;
}
/**
* WebAuthn only works on HTTPS. This is a security-related limitation of the W3C Web Authentication
* specification, not an issue with this plugin :)
*/
if (!Uri::getInstance()->isSsl())
{
return false;
}
// All checks passed; we should allow displaying a WebAuthn login button
$this->allowButtonDisplay = true;
}
return $this->allowButtonDisplay;
}
/**
* Creates additional login buttons
*
* @param string $form The HTML ID of the form we are enclosed in
*
* @return array
*
* @throws Exception
*
* @see AuthenticationHelper::getLoginButtons()
*
* @since 4.0.0
*/
public function onUserLoginButtons(string $form): array
{
// If we determined we should not inject a button return early
if (!$this->mustDisplayButton())
{
return [];
}
// Load the language files
$this->loadLanguage();
// Load necessary CSS and Javascript files
$this->addLoginCSSAndJavascript();
// Return URL
$uri = new Uri(Uri::base() . 'index.php');
$uri->setVar(Joomla::getToken(), '1');
// Unique ID for this button (allows display of multiple modules on the page)
$randomId = 'plg_system_webauthn-' . UserHelper::genRandomPassword(12) . '-' . UserHelper::genRandomPassword(8);
// Set up the JavaScript callback
$url = $uri->toString();
$onClick = "return plgSystemWebauthnLogin('{$form}', '{$url}')";
return [
[
'label' => 'PLG_SYSTEM_WEBAUTHN_LOGIN_LABEL',
'onclick' => $onClick,
'id' => $randomId,
'image' => 'plg_system_webauthn/webauthn-black.png',
'class' => 'plg_system_webauthn_login_button',
],
];
}
/**
* Injects the WebAuthn CSS and Javascript for frontend logins, but only once per page load.
*
* @return void
*
* @since 4.0.0
*/
private function addLoginCSSAndJavascript(): void
{
if ($this->injectedCSSandJS)
{
return;
}
// Set the "don't load again" flag
$this->injectedCSSandJS = true;
// Load the CSS
HTMLHelper::_('stylesheet', 'plg_system_webauthn/button.css', [
'relative' => true,
]
);
// Load the JavaScript
HTMLHelper::_('script', 'plg_system_webauthn/login.js', [
'relative' => true,
]
);
// Load language strings client-side
Text::script('PLG_SYSTEM_WEBAUTHN_ERR_CANNOT_FIND_USERNAME');
Text::script('PLG_SYSTEM_WEBAUTHN_ERR_EMPTY_USERNAME');
Text::script('PLG_SYSTEM_WEBAUTHN_ERR_INVALID_USERNAME');
// Store the current URL as the default return URL after login (or failure)
Joomla::setSessionVar('returnUrl', Uri::current(), 'plg_system_webauthn');
}
}

View File

@ -0,0 +1,171 @@
<?php
/**
* @package Joomla.Plugin
* @subpackage System.updatenotification
*
* @copyright Copyright (C) 2005 - 2019 Open Source Matters, Inc. All rights reserved.
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Plugin\System\Webauthn\PluginTraits;
// Protect from unauthorized access
defined('_JEXEC') or die();
use Exception;
use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Uri\Uri;
use Joomla\Plugin\System\Webauthn\Exception\AjaxNonCmsAppException;
use Joomla\Plugin\System\Webauthn\Helper\Joomla;
use RuntimeException;
/**
* Allows the plugin to handle AJAX requests in the backend of the site, where com_ajax is not available when we are not
* logged in.
*
* @since 4.0.0
*/
trait AjaxHandler
{
/**
* Processes the callbacks from the passwordless login views.
*
* Note: this method is called from Joomla's com_ajax or, in the case of backend logins, through the special
* onAfterInitialize handler we have created to work around com_ajax usage limitations in the backend.
*
* @return void
*
* @throws Exception
*
* @since 4.0.0
*/
public function onAjaxWebauthn(): void
{
// Load the language files
$this->loadLanguage();
/** @var CMSApplication $app */
$app = Factory::getApplication();
$input = $app->input;
// Get the return URL from the session
$returnURL = Joomla::getSessionVar('returnUrl', Uri::base(), 'plg_system_webauthn');
$result = null;
try
{
Joomla::log('system', "Received AJAX callback.");
if (!($app instanceof CMSApplication))
{
throw new AjaxNonCmsAppException;
}
$input = $app->input;
$akaction = $input->getCmd('akaction');
$token = Joomla::getToken();
if ($input->getInt($token, 0) != 1)
{
throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'));
}
// Empty action? No bueno.
if (empty($akaction))
{
throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_AJAX_INVALIDACTION'));
}
// Call the plugin event onAjaxWebauthnSomething where Something is the akaction param.
$eventName = 'onAjaxWebauthn' . ucfirst($akaction);
$results = $app->triggerEvent($eventName, []);
$result = null;
foreach ($results as $r)
{
if (is_null($r))
{
continue;
}
$result = $r;
break;
}
}
catch (AjaxNonCmsAppException $e)
{
Joomla::log('system', "This is not a CMS application", Log::NOTICE);
$result = null;
}
catch (Exception $e)
{
Joomla::log('system', "Callback failure, redirecting to $returnURL.");
Joomla::setSessionVar('returnUrl', null, 'plg_system_webauthn');
$app->enqueueMessage($e->getMessage(), 'error');
$app->redirect($returnURL);
return;
}
if (!is_null($result))
{
switch ($input->getCmd('encoding', 'json'))
{
default:
case 'json':
Joomla::log('system', "Callback complete, returning JSON.");
echo json_encode($result);
break;
case 'jsonhash':
Joomla::log('system', "Callback complete, returning JSON inside ### markers.");
echo '###' . json_encode($result) . '###';
break;
case 'raw':
Joomla::log('system', "Callback complete, returning raw response.");
echo $result;
break;
case 'redirect':
$modifiers = '';
if (isset($result['message']))
{
$type = isset($result['type']) ? $result['type'] : 'info';
$app->enqueueMessage($result['message'], $type);
$modifiers = " and setting a system message of type $type";
}
if (isset($result['url']))
{
Joomla::log('system', "Callback complete, performing redirection to {$result['url']}{$modifiers}.");
$app->redirect($result['url']);
}
Joomla::log('system', "Callback complete, performing redirection to {$result}{$modifiers}.");
$app->redirect($result);
return;
break;
}
$app->close(200);
}
Joomla::log('system', "Null response from AJAX callback, redirecting to $returnURL");
Joomla::setSessionVar('returnUrl', null, 'plg_system_webauthn');
$app->redirect($returnURL);
}
}

View File

@ -0,0 +1,163 @@
<?php
/**
* @package Joomla.Plugin
* @subpackage System.updatenotification
*
* @copyright Copyright (C) 2005 - 2019 Open Source Matters, Inc. All rights reserved.
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Plugin\System\Webauthn\PluginTraits;
// Protect from unauthorized access
defined('_JEXEC') or die();
use Exception;
use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Factory;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\User\UserHelper;
use Joomla\Plugin\System\Webauthn\CredentialRepository;
use Joomla\Plugin\System\Webauthn\Helper\Joomla;
use Throwable;
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\PublicKeyCredentialUserEntity;
/**
* Ajax handler for akaction=challenge
*
* Generates the public key and challenge which is used by the browser when logging in with Webauthn. This is the bit
* which prevents tampering with the login process and replay attacks.
*
* @since 4.0.0
*/
trait AjaxHandlerChallenge
{
/**
* Returns the public key set for the user and a unique challenge in a Public Key Credential Request encoded as
* JSON.
*
* @return string A JSON-encoded object or JSON-encoded false if the username is invalid or no credentials stored
*
* @throws Exception
*
* @since 4.0.0
*/
public function onAjaxWebauthnChallenge()
{
// Load the language files
$this->loadLanguage();
// Initialize objects
/** @var CMSApplication $app */
$app = Factory::getApplication();
$input = $app->input;
$repository = new CredentialRepository;
// Retrieve data from the request
$username = $input->getUsername('username', '');
$returnUrl = base64_encode(
Joomla::getSessionVar('returnUrl', Uri::current(), 'plg_system_webauthn')
);
$returnUrl = $input->getBase64('returnUrl', $returnUrl);
$returnUrl = base64_decode($returnUrl);
// For security reasons the post-login redirection URL must be internal to the site.
if (!Uri::isInternal($returnUrl))
{
// If the URL wasn't internal redirect to the site's root.
$returnUrl = Uri::base();
}
Joomla::setSessionVar('returnUrl', $returnUrl, 'plg_system_webauthn');
// Do I have a username?
if (empty($username))
{
return json_encode(false);
}
// Is the username valid?
try
{
$userId = UserHelper::getUserId($username);
}
catch (Exception $e)
{
$userId = 0;
}
if ($userId <= 0)
{
return json_encode(false);
}
// Load the saved credentials into an array of PublicKeyCredentialDescriptor objects
try
{
$userEntity = new PublicKeyCredentialUserEntity(
'', $repository->getHandleFromUserId($userId), ''
);
$credentials = $repository->findAllForUserEntity($userEntity);
}
catch (Exception $e)
{
return json_encode(false);
}
// No stored credentials?
if (empty($credentials))
{
return json_encode(false);
}
$registeredPublicKeyCredentialDescriptors = [];
/** @var PublicKeyCredentialSource $record */
foreach ($credentials as $record)
{
try
{
$registeredPublicKeyCredentialDescriptors[] = $record->getPublicKeyCredentialDescriptor();
}
catch (Throwable $e)
{
continue;
}
}
// Extensions
$extensions = new AuthenticationExtensionsClientInputs;
// Public Key Credential Request Options
$publicKeyCredentialRequestOptions = new PublicKeyCredentialRequestOptions(
random_bytes(32),
60000,
Uri::getInstance()->toString(['host']),
$registeredPublicKeyCredentialDescriptors,
PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_PREFERRED,
$extensions
);
// Save in session. This is used during the verification stage to prevent replay attacks.
Joomla::setSessionVar(
'publicKeyCredentialRequestOptions',
base64_encode(serialize($publicKeyCredentialRequestOptions)),
'plg_system_webauthn'
);
Joomla::setSessionVar(
'userHandle',
$repository->getHandleFromUserId($userId),
'plg_system_webauthn'
);
Joomla::setSessionVar('userId', $userId, 'plg_system_webauthn');
// Return the JSON encoded data to the caller
return json_encode(
$publicKeyCredentialRequestOptions,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
}
}

View File

@ -0,0 +1,120 @@
<?php
/**
* @package Joomla.Plugin
* @subpackage System.updatenotification
*
* @copyright Copyright (C) 2005 - 2019 Open Source Matters, Inc. All rights reserved.
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Plugin\System\Webauthn\PluginTraits;
// Protect from unauthorized access
defined('_JEXEC') or die();
use Exception;
use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\User\UserFactoryInterface;
use Joomla\Plugin\System\Webauthn\CredentialRepository;
use Joomla\Plugin\System\Webauthn\Helper\CredentialsCreation;
use Joomla\Plugin\System\Webauthn\Helper\Joomla;
use RuntimeException;
use Webauthn\PublicKeyCredentialSource;
/**
* Ajax handler for akaction=create
*
* Handles the browser postback for the credentials creation flow
*
* @since 4.0.0
*/
trait AjaxHandlerCreate
{
/**
* Handle the callback to add a new WebAuthn authenticator
*
* @return string
*
* @throws Exception
*
* @since 4.0.0
*/
public function onAjaxWebauthnCreate(): string
{
// Load the language files
$this->loadLanguage();
/**
* Fundamental sanity check: this callback is only allowed after a Public Key has been created server-side and
* the user it was created for matches the current user.
*
* This is also checked in the validateAuthenticationData() so why check here? In case we have the wrong user
* I need to fail early with a Joomla error page instead of falling through the code and possibly displaying
* someone else's Webauthn configuration thus mitigating a major privacy and security risk. So, please, DO NOT
* remove this sanity check!
*/
$storedUserId = Joomla::getSessionVar('registration_user_id', 0, 'plg_system_webauthn');
$thatUser = empty($storedUserId) ?
Factory::getApplication()->getIdentity() :
Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($storedUserId);
$myUser = Factory::getApplication()->getIdentity();
if ($thatUser->guest || ($thatUser->id != $myUser->id))
{
// Unset the session variables used for registering authenticators (security precaution).
Joomla::unsetSessionVar('registration_user_id', 'plg_system_webauthn');
Joomla::unsetSessionVar('publicKeyCredentialCreationOptions', 'plg_system_webauthn');
// Politely tell the presumed hacker trying to abuse this callback to go away.
throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_USER'));
}
// Get the credentials repository object. It's outside the try-catch because I also need it to display the GUI.
$credentialRepository = new CredentialRepository;
// Try to validate the browser data. If there's an error I won't save anything and pass the message to the GUI.
try
{
/** @var CMSApplication $app */
$app = Factory::getApplication();
$input = $app->input;
// Retrieve the data sent by the device
$data = $input->get('data', '', 'raw');
$publicKeyCredentialSource = CredentialsCreation::validateAuthenticationData($data);
if (!is_object($publicKeyCredentialSource) || !($publicKeyCredentialSource instanceof PublicKeyCredentialSource))
{
throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_NO_ATTESTED_DATA'));
}
$credentialRepository->saveCredentialSource($publicKeyCredentialSource);
}
catch (Exception $e)
{
$error = $e->getMessage();
$publicKeyCredentialSource = null;
}
// Unset the session variables used for registering authenticators (security precaution).
Joomla::unsetSessionVar('registration_user_id', 'plg_system_webauthn');
Joomla::unsetSessionVar('publicKeyCredentialCreationOptions', 'plg_system_webauthn');
// Render the GUI and return it
$layoutParameters = [
'user' => $thatUser,
'allow_add' => $thatUser->id == $myUser->id,
'credentials' => $credentialRepository->getAll($thatUser->id),
];
if (isset($error) && !empty($error))
{
$layoutParameters['error'] = $error;
}
return Joomla::renderLayout('plugins.system.webauthn.manage', $layoutParameters);
}
}

View File

@ -0,0 +1,92 @@
<?php
/**
* @package Joomla.Plugin
* @subpackage System.updatenotification
*
* @copyright Copyright (C) 2005 - 2019 Open Source Matters, Inc. All rights reserved.
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Plugin\System\Webauthn\PluginTraits;
// Protect from unauthorized access
defined('_JEXEC') or die();
use Exception;
use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Factory;
use Joomla\Plugin\System\Webauthn\CredentialRepository;
/**
* Ajax handler for akaction=savelabel
*
* Deletes a security key
*
* @since 4.0.0
*/
trait AjaxHandlerDelete
{
/**
* Handle the callback to remove an authenticator
*
* @return boolean
* @throws Exception
*
* @since 4.0.0
*/
public function onAjaxWebauthnDelete(): bool
{
// Load the language files
$this->loadLanguage();
// Initialize objects
/** @var CMSApplication $app */
$app = Factory::getApplication();
$input = $app->input;
$repository = new CredentialRepository;
// Retrieve data from the request
$credentialId = $input->getBase64('credential_id', '');
// Is this a valid credential?
if (empty($credentialId))
{
return false;
}
$credentialId = base64_decode($credentialId);
if (empty($credentialId) || !$repository->has($credentialId))
{
return false;
}
// Make sure I am editing my own key
try
{
$credentialHandle = $repository->getUserHandleFor($credentialId);
$myHandle = $repository->getHandleFromUserId($app->getIdentity()->id);
}
catch (Exception $e)
{
return false;
}
if ($credentialHandle !== $myHandle)
{
return false;
}
// Delete the record
try
{
$repository->remove($credentialId);
}
catch (Exception $e)
{
return false;
}
return true;
}
}

View File

@ -0,0 +1,270 @@
<?php
/**
* @package Joomla.Plugin
* @subpackage System.updatenotification
*
* @copyright Copyright (C) 2005 - 2019 Open Source Matters, Inc. All rights reserved.
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Plugin\System\Webauthn\PluginTraits;
// Protect from unauthorized access
defined('_JEXEC') or die();
use CBOR\Decoder;
use CBOR\OtherObject\OtherObjectManager;
use CBOR\Tag\TagObjectManager;
use Cose\Algorithm\Manager;
use Cose\Algorithm\Signature\ECDSA;
use Cose\Algorithm\Signature\EdDSA;
use Cose\Algorithm\Signature\RSA;
use Exception;
use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Authentication\Authentication;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\User\UserFactoryInterface;
use Joomla\Plugin\System\Webauthn\CredentialRepository;
use Joomla\Plugin\System\Webauthn\Helper\Joomla;
use Laminas\Diactoros\ServerRequestFactory;
use RuntimeException;
use Throwable;
use Webauthn\AttestationStatement\AndroidKeyAttestationStatementSupport;
use Webauthn\AttestationStatement\AttestationObjectLoader;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AttestationStatement\FidoU2FAttestationStatementSupport;
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
use Webauthn\AttestationStatement\PackedAttestationStatementSupport;
use Webauthn\AttestationStatement\TPMAttestationStatementSupport;
use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
use Webauthn\AuthenticatorAssertionResponse;
use Webauthn\AuthenticatorAssertionResponseValidator;
use Webauthn\PublicKeyCredentialLoader;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthn\TokenBinding\TokenBindingNotSupportedHandler;
/**
* Ajax handler for akaction=login
*
* Verifies the response received from the browser and logs in the user
*
* @since 4.0.0
*/
trait AjaxHandlerLogin
{
/**
* Returns the public key set for the user and a unique challenge in a Public Key Credential Request encoded as
* JSON.
*
* @return void
*
* @throws Exception
* @since 4.0.0
*/
public function onAjaxWebauthnLogin(): void
{
// Load the language files
$this->loadLanguage();
$returnUrl = Joomla::getSessionVar('returnUrl', Uri::base(), 'plg_system_webauthn');
$userId = Joomla::getSessionVar('userId', 0, 'plg_system_webauthn');
try
{
// Sanity check
if (empty($userId))
{
throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
}
// Make sure the user exists
$user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);
if ($user->id != $userId)
{
throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
}
// Validate the authenticator response
$this->validateResponse();
// Login the user
Joomla::log('system', "Logging in the user", Log::INFO);
Joomla::loginUser((int) $userId);
}
catch (Throwable $e)
{
Joomla::setSessionVar('publicKeyCredentialRequestOptions', null, 'plg_system_webauthn');
Joomla::setSessionVar('userHandle', null, 'plg_system_webauthn');
$response = Joomla::getAuthenticationResponseObject();
$response->status = Authentication::STATUS_UNKNOWN;
// phpcs:ignore
$response->error_message = $e->getMessage();
Joomla::log('system', sprintf("Received login failure. Message: %s", $e->getMessage()), Log::ERROR);
// This also enqueues the login failure message for display after redirection. Look for JLog in that method.
Joomla::processLoginFailure($response, null, 'system');
}
finally
{
/**
* This code needs to run no matter if the login succeeded or failed. It prevents replay attacks and takes
* the user back to the page they started from.
*/
// Remove temporary information for security reasons
Joomla::setSessionVar('publicKeyCredentialRequestOptions', null, 'plg_system_webauthn');
Joomla::setSessionVar('userHandle', null, 'plg_system_webauthn');
Joomla::setSessionVar('returnUrl', null, 'plg_system_webauthn');
Joomla::setSessionVar('userId', null, 'plg_system_webauthn');
// Redirect back to the page we were before.
Factory::getApplication()->redirect($returnUrl);
}
}
/**
* Validate the authenticator response sent to us by the browser.
*
* @return void
*
* @throws Exception
*
* @since 4.0.0
*/
private function validateResponse(): void
{
// Initialize objects
/** @var CMSApplication $app */
$app = Factory::getApplication();
$input = $app->input;
$credentialRepository = new CredentialRepository;
// Retrieve data from the request and session
$data = $input->getBase64('data', '');
$data = base64_decode($data);
if (empty($data))
{
throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
}
$publicKeyCredentialRequestOptions = $this->getPKCredentialRequestOptions();
// Cose Algorithm Manager
$coseAlgorithmManager = new Manager;
$coseAlgorithmManager->add(new ECDSA\ES256);
$coseAlgorithmManager->add(new ECDSA\ES512);
$coseAlgorithmManager->add(new EdDSA\EdDSA);
$coseAlgorithmManager->add(new RSA\RS1);
$coseAlgorithmManager->add(new RSA\RS256);
$coseAlgorithmManager->add(new RSA\RS512);
// Create a CBOR Decoder object
$otherObjectManager = new OtherObjectManager;
$tagObjectManager = new TagObjectManager;
$decoder = new Decoder($tagObjectManager, $otherObjectManager);
// Attestation Statement Support Manager
$attestationStatementSupportManager = new AttestationStatementSupportManager;
$attestationStatementSupportManager->add(new NoneAttestationStatementSupport);
$attestationStatementSupportManager->add(new FidoU2FAttestationStatementSupport($decoder));
/*
$attestationStatementSupportManager->add(
new AndroidSafetyNetAttestationStatementSupport(
HttpFactory::getHttp(), 'GOOGLE_SAFETYNET_API_KEY', new RequestFactory
)
);
*/
$attestationStatementSupportManager->add(new AndroidKeyAttestationStatementSupport($decoder));
$attestationStatementSupportManager->add(new TPMAttestationStatementSupport);
$attestationStatementSupportManager->add(new PackedAttestationStatementSupport($decoder, $coseAlgorithmManager));
// Attestation Object Loader
$attestationObjectLoader = new AttestationObjectLoader($attestationStatementSupportManager, $decoder);
// Public Key Credential Loader
$publicKeyCredentialLoader = new PublicKeyCredentialLoader($attestationObjectLoader, $decoder);
// The token binding handler
$tokenBindingHandler = new TokenBindingNotSupportedHandler;
// Extension Output Checker Handler
$extensionOutputCheckerHandler = new ExtensionOutputCheckerHandler;
// Authenticator Assertion Response Validator
$authenticatorAssertionResponseValidator = new AuthenticatorAssertionResponseValidator(
$credentialRepository,
$decoder,
$tokenBindingHandler,
$extensionOutputCheckerHandler,
$coseAlgorithmManager
);
// We init the Symfony Request object
$request = ServerRequestFactory::fromGlobals();
// Load the data
$publicKeyCredential = $publicKeyCredentialLoader->load($data);
$response = $publicKeyCredential->getResponse();
// Check if the response is an Authenticator Assertion Response
if (!$response instanceof AuthenticatorAssertionResponse)
{
throw new RuntimeException('Not an authenticator assertion response');
}
// Check the response against the attestation request
$userHandle = Joomla::getSessionVar('userHandle', null, 'plg_system_webauthn');
/** @var AuthenticatorAssertionResponse $authenticatorAssertionResponse */
$authenticatorAssertionResponse = $publicKeyCredential->getResponse();
$authenticatorAssertionResponseValidator->check(
$publicKeyCredential->getRawId(),
$authenticatorAssertionResponse,
$publicKeyCredentialRequestOptions,
$request,
$userHandle
);
}
/**
* Retrieve the public key credential request options saved in the session. If they do not exist or are corrupt it
* is a hacking attempt and we politely tell the hacker to go away.
*
* @return PublicKeyCredentialRequestOptions
*
* @since 4.0.0
*/
private function getPKCredentialRequestOptions(): PublicKeyCredentialRequestOptions
{
$encodedOptions = Joomla::getSessionVar('publicKeyCredentialRequestOptions', null, 'plg_system_webauthn');
if (empty($encodedOptions))
{
throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
}
try
{
$publicKeyCredentialCreationOptions = unserialize(base64_decode($encodedOptions));
}
catch (Exception $e)
{
throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
}
if (!is_object($publicKeyCredentialCreationOptions)
|| !($publicKeyCredentialCreationOptions instanceof PublicKeyCredentialRequestOptions))
{
throw new RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
}
return $publicKeyCredentialCreationOptions;
}
}

View File

@ -0,0 +1,97 @@
<?php
/**
* @package Joomla.Plugin
* @subpackage System.updatenotification
*
* @copyright Copyright (C) 2005 - 2019 Open Source Matters, Inc. All rights reserved.
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Plugin\System\Webauthn\PluginTraits;
// Protect from unauthorized access
defined('_JEXEC') or die();
use Exception;
use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Factory;
use Joomla\Plugin\System\Webauthn\CredentialRepository;
/**
* Ajax handler for akaction=savelabel
*
* Stores a new label for a security key
*
* @since 4.0.0
*/
trait AjaxHandlerSaveLabel
{
/**
* Handle the callback to rename an authenticator
*
* @return boolean
*
* @throws Exception
*
* @since 4.0.0
*/
public function onAjaxWebauthnSavelabel(): bool
{
// Initialize objects
/** @var CMSApplication $app */
$app = Factory::getApplication();
$input = $app->input;
$repository = new CredentialRepository;
// Retrieve data from the request
$credentialId = $input->getBase64('credential_id', '');
$newLabel = $input->getString('new_label', '');
// Is this a valid credential?
if (empty($credentialId))
{
return false;
}
$credentialId = base64_decode($credentialId);
if (empty($credentialId) || !$repository->has($credentialId))
{
return false;
}
// Make sure I am editing my own key
try
{
$credentialHandle = $repository->getUserHandleFor($credentialId);
$myHandle = $repository->getHandleFromUserId($app->getIdentity()->id);
}
catch (Exception $e)
{
return false;
}
if ($credentialHandle !== $myHandle)
{
return false;
}
// Make sure the new label is not empty
if (empty($newLabel))
{
return false;
}
// Save the new label
try
{
$repository->setLabel($credentialId, $newLabel);
}
catch (Exception $e)
{
return false;
}
return true;
}
}

View File

@ -0,0 +1,69 @@
<?php
/**
* @package Joomla.Plugin
* @subpackage System.updatenotification
*
* @copyright Copyright (C) 2005 - 2019 Open Source Matters, Inc. All rights reserved.
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Plugin\System\Webauthn\PluginTraits;
// Protect from unauthorized access
defined('_JEXEC') or die();
use Exception;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseDriver;
use Joomla\Plugin\System\Webauthn\Helper\Joomla;
use Joomla\Utilities\ArrayHelper;
/**
* Delete all WebAuthn credentials for a particular user
*
* @since 4.0.0
*/
trait UserDeletion
{
/**
* Remove all passwordless credential information for the given user ID.
*
* This method is called after user data is deleted from the database.
*
* @param array $user Holds the user data
* @param bool $success True if user was successfully stored in the database
* @param string $msg Message
*
* @return boolean
*
* @throws Exception
*
* @since 4.0.0
*/
public function onUserAfterDelete(array $user, bool $success, ?string $msg): bool
{
if (!$success)
{
return false;
}
$userId = ArrayHelper::getValue($user, 'id', 0, 'int');
if ($userId)
{
Joomla::log('system', "Removing WebAuthn Passwordless Login information for deleted user #{$userId}");
/** @var DatabaseDriver $db */
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true)
->delete($db->qn('#__webauthn_credentials'))
->where($db->qn('user_id') . ' = :userId')
->bind(':userId', $userId);
$db->setQuery($query)->execute();
}
return true;
}
}

View File

@ -0,0 +1,106 @@
<?php
/**
* @package Joomla.Plugin
* @subpackage System.updatenotification
*
* @copyright Copyright (C) 2005 - 2019 Open Source Matters, Inc. All rights reserved.
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Plugin\System\Webauthn\PluginTraits;
// Protect from unauthorized access
defined('_JEXEC') or die();
use Exception;
use Joomla\CMS\Factory;
use Joomla\CMS\Form\Form;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\User\UserFactoryInterface;
use Joomla\Plugin\System\Webauthn\Helper\Joomla;
use Joomla\Registry\Registry;
/**
* Add extra fields in the User Profile page.
*
* This class only injects the custom form fields. The actual interface is rendered through JFormFieldWebauthn.
*
* @see JFormFieldWebauthn::getInput()
*
* @since 4.0.0
*/
trait UserProfileFields
{
/**
* Adds additional fields to the user editing form
*
* @param Form $form The form to be altered.
* @param mixed $data The associated data for the form.
*
* @return boolean
*
* @throws Exception
*
* @since 4.0.0
*/
public function onContentPrepareForm(Form $form, $data)
{
// This feature only applies to HTTPS sites.
if (!Uri::getInstance()->isSsl())
{
return true;
}
// Check we are manipulating a valid form.
if (!($form instanceof Form))
{
return true;
}
$name = $form->getName();
if (!in_array($name, ['com_admin.profile', 'com_users.user', 'com_users.profile', 'com_users.registration']))
{
return true;
}
// Get the user ID
$id = null;
if (is_array($data))
{
$id = isset($data['id']) ? $data['id'] : null;
}
elseif (is_object($data) && is_null($data) && ($data instanceof Registry))
{
$id = $data->get('id');
}
elseif (is_object($data) && !is_null($data))
{
$id = isset($data->id) ? $data->id : null;
}
$user = empty($id) ? Factory::getApplication()->getIdentity() : Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($id);
// Make sure the loaded user is the correct one
if ($user->id != $id)
{
return true;
}
// Make sure I am either editing myself OR I am a Super User
if (!Joomla::canEditUser($user))
{
return true;
}
// Add the fields to the form.
Joomla::log('system', 'Injecting WebAuthn Passwordless Login fields in user profile edit page');
Form::addFormPath(dirname(__FILE__) . '/../../fields');
$this->loadLanguage();
$form->loadFile('webauthn', false);
return true;
}
}

View File

@ -0,0 +1,76 @@
<?php
/**
* @package Joomla.Plugin
* @subpackage System.updatenotification
*
* @copyright Copyright (C) 2005 - 2019 Open Source Matters, Inc. All rights reserved.
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
// Protect from unauthorized access
defined('_JEXEC') or die();
use Joomla\CMS\Factory;
use Joomla\CMS\Form\FormField;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\User\UserFactoryInterface;
use Joomla\Plugin\System\Webauthn\CredentialRepository;
use Joomla\Plugin\System\Webauthn\Helper\Joomla;
/**
* Custom Joomla Form Field to display the WebAuthn interface
*
* @since 4.0.0
*/
class JFormFieldWebauthn extends FormField
{
/**
* Element name
*
* @var string
*
* @since 4.0.0
*/
// phpcs:ignore
protected $_name = 'Webauthn';
/**
* Returns the input field's HTML
*
* @return string
* @throws Exception
*
* @since 4.0.0
*/
public function getInput()
{
$userId = $this->form->getData()->get('id', null);
if (is_null($userId))
{
return Text::_('PLG_SYSTEM_WEBAUTHN_ERR_NOUSER');
}
HTMLHelper::_('script', 'plg_system_webauthn/management.js', [
'relative' => true,
'framework' => true,
]
);
Text::script('PLG_SYSTEM_WEBAUTHN_ERR_NO_BROWSER_SUPPORT', true);
Text::script('PLG_SYSTEM_WEBAUTHN_MANAGE_BTN_SAVE_LABEL', true);
Text::script('PLG_SYSTEM_WEBAUTHN_MANAGE_BTN_CANCEL_LABEL', true);
Text::script('PLG_SYSTEM_WEBAUTHN_MSG_SAVED_LABEL', true);
Text::script('PLG_SYSTEM_WEBAUTHN_ERR_LABEL_NOT_SAVED', true);
$app = Factory::getApplication();
$credentialRepository = new CredentialRepository;
return Joomla::renderLayout('plugins.system.webauthn.manage', [
'user' => Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId),
'allow_add' => $userId == $app->getIdentity()->id,
'credentials' => $credentialRepository->getAll($userId),
]
);
}
}

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<form>
<fields name="webauthn" addfieldpath="plugins/system/webauthn/fields">
<fieldset name="webauthn"
label="PLG_SYSTEM_WEBAUTHN_HEADER"
>
<field
name="webauthn"
type="webauthn"
label="PLG_SYSTEM_WEBAUTHN_FIELD_LABEL"
description="PLG_SYSTEM_WEBAUTHN_FIELD_DESC"
required="false"
readonly="false"
default=""
/>
</fieldset>
</fields>
</form>

View File

@ -0,0 +1,86 @@
<?php
/**
* @package Joomla.Plugin
* @subpackage System.updatenotification
*
* @copyright Copyright (C) 2005 - 2019 Open Source Matters, Inc. All rights reserved.
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
// Protect from unauthorized access
defined('_JEXEC') or die();
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\DispatcherInterface;
use Joomla\Plugin\System\Webauthn\Helper\Joomla;
use Joomla\Plugin\System\Webauthn\PluginTraits\AdditionalLoginButtons;
use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandler;
use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandlerChallenge;
use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandlerCreate;
use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandlerDelete;
use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandlerLogin;
use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandlerSaveLabel;
use Joomla\Plugin\System\Webauthn\PluginTraits\ButtonsInUserPage;
use Joomla\Plugin\System\Webauthn\PluginTraits\UserDeletion;
use Joomla\Plugin\System\Webauthn\PluginTraits\UserProfileFields;
// Register a PSR-4 autoloader for this plugin's classes if necessary
if (!class_exists('Joomla\\Plugin\\System\\Webauthn\\Helper\\Joomla', true))
{
JLoader::registerNamespace('Joomla\\Plugin\\System\\Webauthn', __DIR__ . '/Webauthn', false, false, 'psr4');
}
/**
* WebAuthn Passwordless Login plugin
*
* The plugin features are broken down into Traits for the sole purpose of making an otherwise supermassive class
* somewhat manageable. You can find the Traits inside the Webauthn/PluginTraits folder.
*
* @since 4.0.0
*/
// phpcs:ignore
class plgSystemWebauthn extends CMSPlugin
{
// AJAX request handlers
use AjaxHandler;
use AjaxHandlerCreate;
use AjaxHandlerSaveLabel;
use AjaxHandlerDelete;
use AjaxHandlerChallenge;
use AjaxHandlerLogin;
// Custom user profile fields
use UserProfileFields;
// Handle user profile deletion
use UserDeletion;
// Add WebAuthn buttons
use AdditionalLoginButtons;
/**
* Constructor. Loads the language files as well.
*
* @param DispatcherInterface $subject The object to observe
* @param array $config An optional associative array of configuration
* settings. Recognized key values include 'name',
* 'group', 'params', 'language (this list is not meant
* to be comprehensive).
*
* @since 4.0.0
*/
public function __construct(&$subject, array $config = [])
{
parent::__construct($subject, $config);
/**
* Note: Do NOT try to load the language in the constructor. This is called before Joomla initializes the
* application language. Therefore the temporary Joomla language object and all loaded strings in it will be
* destroyed on application initialization. As a result we need to call loadLanguage() in each method
* individually, even though all methods make use of language strings.
*/
// Register a debug log file writer
Joomla::addLogger('system');
}
}

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<extension version="4.0.0" type="plugin" group="system" method="upgrade">
<name>PLG_SYSTEM_WEBAUTHN</name>
<version>4.0.0</version>
<creationDate>2019-07-02</creationDate>
<author>Joomla! Project</author>
<authorEmail>admin@joomla.org</authorEmail>
<authorUrl>www.joomla.org</authorUrl>
<copyright>Copyright (C) 2005 - 2019 Open Source Matters. All rights reserved.</copyright>
<license>GNU General Public License version 2 or later; see LICENSE.txt</license>
<description>PLG_SYSTEM_WEBAUTHN_DESCRIPTION</description>
<files>
<filename plugin="webauthn">webauthn.php</filename>
<folder>fields</folder>
<folder>Webauthn</folder>
</files>
<languages folder="language">
<language tag="en-GB">en-GB/plg_system_webauthn.ini</language>
<language tag="en-GB">en-GB/plg_system_webauthn.sys.ini</language>
</languages>
</extension>