webauthn
This commit is contained in:
parent
9e1e0634e6
commit
8a014f3a8a
|
@ -544,7 +544,7 @@ class PublicKeyCredentialSourceRepositorySQLite implements PublicKeyCredentialSo
|
||||||
*/
|
*/
|
||||||
public function searchDevices(string $searchTerm) {
|
public function searchDevices(string $searchTerm) {
|
||||||
$pdo = $this->getPDO();
|
$pdo = $this->getPDO();
|
||||||
$statement = $pdo->prepare('select * from ' . self::TABLE_NAME . ' where userId like :searchTerm');
|
$statement = $pdo->prepare('select * from ' . self::TABLE_NAME . ' where userId like :searchTerm order by userId,registrationTime');
|
||||||
$statement->execute(array(
|
$statement->execute(array(
|
||||||
':searchTerm' => $searchTerm
|
':searchTerm' => $searchTerm
|
||||||
));
|
));
|
||||||
|
|
|
@ -548,7 +548,9 @@ input.markOk {
|
||||||
}
|
}
|
||||||
|
|
||||||
div.lam-webauthn-results {
|
div.lam-webauthn-results {
|
||||||
max-height: 10rem;
|
max-height: 20rem;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -841,6 +841,14 @@ window.lam.form.autoTrim = function() {
|
||||||
|
|
||||||
window.lam.dialog = window.lam.dialog || {};
|
window.lam.dialog = window.lam.dialog || {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a dialog message.
|
||||||
|
*
|
||||||
|
* @param title dialog title
|
||||||
|
* @param okText ok button text
|
||||||
|
* @param divId DIV id with dialog content
|
||||||
|
* @param callbackFunction callback function (optional)
|
||||||
|
*/
|
||||||
window.lam.dialog.showMessage = function(title, okText, divId, callbackFunction) {
|
window.lam.dialog.showMessage = function(title, okText, divId, callbackFunction) {
|
||||||
var buttonList = {};
|
var buttonList = {};
|
||||||
buttonList[okText] = function() {
|
buttonList[okText] = function() {
|
||||||
|
@ -1388,14 +1396,14 @@ window.lam.webauthn.run = function(prefix) {
|
||||||
form.submit();
|
form.submit();
|
||||||
return;
|
return;
|
||||||
});
|
});
|
||||||
var token = jQuery('#sec_token').val();
|
const token = jQuery('#sec_token').val();
|
||||||
// check for webauthn support
|
// check for webauthn support
|
||||||
if (!navigator.credentials || (typeof(PublicKeyCredential) === "undefined")) {
|
if (!navigator.credentials || (typeof(PublicKeyCredential) === "undefined")) {
|
||||||
jQuery('.webauthn-error').show();
|
jQuery('.webauthn-error').show();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var data = {
|
const data = {
|
||||||
action: 'status',
|
action: 'status',
|
||||||
jsonInput: '',
|
jsonInput: '',
|
||||||
sec_token: token
|
sec_token: token
|
||||||
|
@ -1407,7 +1415,25 @@ window.lam.webauthn.run = function(prefix) {
|
||||||
})
|
})
|
||||||
.done(function(jsonData) {
|
.done(function(jsonData) {
|
||||||
if (jsonData.action === 'register') {
|
if (jsonData.action === 'register') {
|
||||||
window.lam.webauthn.register(jsonData.registration);
|
const successCallback = function (publicKeyCredential) {
|
||||||
|
const form = jQuery("#2faform");
|
||||||
|
const response = btoa(JSON.stringify(publicKeyCredential));
|
||||||
|
form.append('<input type="hidden" name="sig_response" value="' + response + '"/>');
|
||||||
|
form.submit();
|
||||||
|
};
|
||||||
|
const errorCallback = function(error) {
|
||||||
|
let errorDiv = jQuery('#generic-webauthn-error');
|
||||||
|
let buttonLabel = errorDiv.data('button');
|
||||||
|
let dialogTitle = errorDiv.data('title');
|
||||||
|
errorDiv.text(error.message);
|
||||||
|
window.lam.dialog.showMessage(dialogTitle,
|
||||||
|
buttonLabel,
|
||||||
|
'generic-webauthn-error',
|
||||||
|
function () {
|
||||||
|
jQuery('#btn_logout').click();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.lam.webauthn.register(jsonData.registration, successCallback, errorCallback);
|
||||||
}
|
}
|
||||||
else if (jsonData.action === 'authenticate') {
|
else if (jsonData.action === 'authenticate') {
|
||||||
window.lam.webauthn.authenticate(jsonData.authentication);
|
window.lam.webauthn.authenticate(jsonData.authentication);
|
||||||
|
@ -1422,23 +1448,27 @@ window.lam.webauthn.run = function(prefix) {
|
||||||
* Performs a webauthn registration.
|
* Performs a webauthn registration.
|
||||||
*
|
*
|
||||||
* @param publicKey registration object
|
* @param publicKey registration object
|
||||||
|
* @param successCallback callback function in case of all went fine
|
||||||
|
* @param errorCallback callback function in case of an error
|
||||||
*/
|
*/
|
||||||
window.lam.webauthn.register = function(publicKey) {
|
window.lam.webauthn.register = function(publicKey, successCallback, errorCallback) {
|
||||||
publicKey.challenge = Uint8Array.from(window.atob(publicKey.challenge), c=>c.charCodeAt(0));
|
if (!(publicKey.challenge instanceof Uint8Array)) {
|
||||||
publicKey.user.id = Uint8Array.from(window.atob(publicKey.user.id), c=>c.charCodeAt(0));
|
publicKey.challenge = Uint8Array.from(window.atob(publicKey.challenge), c=>c.charCodeAt(0));
|
||||||
publicKey.rp.icon = window.location.href.substring(0, window.location.href.lastIndexOf("/")) + publicKey.rp.icon;
|
publicKey.user.id = Uint8Array.from(window.atob(publicKey.user.id), c=>c.charCodeAt(0));
|
||||||
if (publicKey.excludeCredentials) {
|
publicKey.rp.icon = window.location.href.substring(0, window.location.href.lastIndexOf("/")) + publicKey.rp.icon;
|
||||||
for (let i = 0; i < publicKey.excludeCredentials.length; i++) {
|
if (publicKey.excludeCredentials) {
|
||||||
let idOrig = publicKey.excludeCredentials[i]['id'];
|
for (let i = 0; i < publicKey.excludeCredentials.length; i++) {
|
||||||
idOrig = idOrig.replace(/-/g, "+").replace(/_/g, "/");
|
let idOrig = publicKey.excludeCredentials[i]['id'];
|
||||||
let idOrigDecoded = atob(idOrig);
|
idOrig = idOrig.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
let idArray = Uint8Array.from(idOrigDecoded, c => c.charCodeAt(0))
|
let idOrigDecoded = atob(idOrig);
|
||||||
publicKey.excludeCredentials[i]['id'] = idArray;
|
let idArray = Uint8Array.from(idOrigDecoded, c => c.charCodeAt(0))
|
||||||
|
publicKey.excludeCredentials[i]['id'] = idArray;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
navigator.credentials.create({publicKey})
|
navigator.credentials.create({publicKey})
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
let publicKeyCredential = {
|
const publicKeyCredential = {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
type: data.type,
|
type: data.type,
|
||||||
rawId: window.lam.webauthn.arrayToBase64String(new Uint8Array(data.rawId)),
|
rawId: window.lam.webauthn.arrayToBase64String(new Uint8Array(data.rawId)),
|
||||||
|
@ -1447,22 +1477,10 @@ window.lam.webauthn.register = function(publicKey) {
|
||||||
attestationObject: window.lam.webauthn.arrayToBase64String(new Uint8Array(data.response.attestationObject))
|
attestationObject: window.lam.webauthn.arrayToBase64String(new Uint8Array(data.response.attestationObject))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let form = jQuery("#2faform");
|
successCallback(publicKeyCredential);
|
||||||
let response = btoa(JSON.stringify(publicKeyCredential));
|
|
||||||
form.append('<input type="hidden" name="sig_response" value="' + response + '"/>');
|
|
||||||
form.submit();
|
|
||||||
}, function (error) {
|
}, function (error) {
|
||||||
console.log(error.message);
|
console.log(error.message);
|
||||||
let errorDiv = jQuery('#generic-webauthn-error');
|
errorCallback(error);
|
||||||
let buttonLabel = errorDiv.data('button');
|
|
||||||
let dialogTitle = errorDiv.data('title');
|
|
||||||
errorDiv.text(error.message);
|
|
||||||
window.lam.dialog.showMessage(dialogTitle,
|
|
||||||
buttonLabel,
|
|
||||||
'generic-webauthn-error',
|
|
||||||
function () {
|
|
||||||
jQuery('#btn_logout').click();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1598,7 +1616,16 @@ window.lam.webauthn.removeDevice = function(event) {
|
||||||
window.lam.webauthn.removeOwnDevice = function(event) {
|
window.lam.webauthn.removeOwnDevice = function(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const element = jQuery(event.target);
|
const element = jQuery(event.target);
|
||||||
window.lam.webauthn.removeDeviceDialog(element, 'webauthnOwnDevices');
|
const successCallback = function () {
|
||||||
|
const form = jQuery("#webauthnform");
|
||||||
|
jQuery('<input>').attr({
|
||||||
|
type: 'hidden',
|
||||||
|
name: 'removed',
|
||||||
|
value: 'true'
|
||||||
|
}).appendTo(form);
|
||||||
|
form.submit();
|
||||||
|
};
|
||||||
|
window.lam.webauthn.removeDeviceDialog(element, 'webauthnOwnDevices', successCallback);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1607,15 +1634,16 @@ window.lam.webauthn.removeOwnDevice = function(event) {
|
||||||
*
|
*
|
||||||
* @param element delete button
|
* @param element delete button
|
||||||
* @param action action for request (delete|deleteOwn)
|
* @param action action for request (delete|deleteOwn)
|
||||||
|
* @param successCallback callback if all was fine (optional)
|
||||||
*/
|
*/
|
||||||
window.lam.webauthn.removeDeviceDialog = function(element, action) {
|
window.lam.webauthn.removeDeviceDialog = function(element, action, successCallback) {
|
||||||
const dialogTitle = element.data('dialogtitle');
|
const dialogTitle = element.data('dialogtitle');
|
||||||
const okText = element.data('oktext');
|
const okText = element.data('oktext');
|
||||||
const cancelText = element.data('canceltext');
|
const cancelText = element.data('canceltext');
|
||||||
let buttonList = {};
|
let buttonList = {};
|
||||||
buttonList[okText] = function() {
|
buttonList[okText] = function() {
|
||||||
jQuery('#webauthnDeleteConfirm').dialog('close');
|
jQuery('#webauthnDeleteConfirm').dialog('close');
|
||||||
window.lam.webauthn.sendRemoveDeviceRequest(element, action);
|
window.lam.webauthn.sendRemoveDeviceRequest(element, action, successCallback);
|
||||||
};
|
};
|
||||||
buttonList[cancelText] = function() {
|
buttonList[cancelText] = function() {
|
||||||
jQuery(this).dialog("close");
|
jQuery(this).dialog("close");
|
||||||
|
@ -1634,8 +1662,9 @@ window.lam.webauthn.removeDeviceDialog = function(element, action) {
|
||||||
*
|
*
|
||||||
* @param element button element
|
* @param element button element
|
||||||
* @param action action (delete|deleteOwn)
|
* @param action action (delete|deleteOwn)
|
||||||
|
* @param successCallback callback if all was fine (optional)
|
||||||
*/
|
*/
|
||||||
window.lam.webauthn.sendRemoveDeviceRequest = function(element, action) {
|
window.lam.webauthn.sendRemoveDeviceRequest = function(element, action, successCallback) {
|
||||||
const dn = element.data('dn');
|
const dn = element.data('dn');
|
||||||
const credential = element.data('credential');
|
const credential = element.data('credential');
|
||||||
const resultDiv = jQuery('#webauthn_results');
|
const resultDiv = jQuery('#webauthn_results');
|
||||||
|
@ -1653,13 +1682,51 @@ window.lam.webauthn.sendRemoveDeviceRequest = function(element, action) {
|
||||||
data: data
|
data: data
|
||||||
})
|
})
|
||||||
.done(function(jsonData) {
|
.done(function(jsonData) {
|
||||||
resultDiv.html(jsonData.content);
|
if (successCallback) {
|
||||||
|
successCallback();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
resultDiv.html(jsonData.content);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.fail(function() {
|
.fail(function() {
|
||||||
console.log('Webauthn device deletion failed');
|
console.log('Webauthn device deletion failed');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a user's own webauthn device.
|
||||||
|
*
|
||||||
|
* @param event click event
|
||||||
|
*/
|
||||||
|
window.lam.webauthn.registerOwnDevice = function(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const element = jQuery(event.target);
|
||||||
|
const dn = element.data('dn');
|
||||||
|
const tokenValue = element.data('sec_token_value');
|
||||||
|
const tokenName = element.data('sec_token_name');
|
||||||
|
const publicKey = element.data('publickey');
|
||||||
|
const successCallback = function (publicKeyCredential) {
|
||||||
|
const form = jQuery("#webauthnform");
|
||||||
|
const response = btoa(JSON.stringify(publicKeyCredential));
|
||||||
|
const registrationData = jQuery('#registrationData');
|
||||||
|
registrationData.val(response);
|
||||||
|
form.submit();
|
||||||
|
};
|
||||||
|
const errorCallback = function (error) {
|
||||||
|
let errorDiv = jQuery('#generic-webauthn-error');
|
||||||
|
let buttonLabel = errorDiv.data('button');
|
||||||
|
let dialogTitle = errorDiv.data('title');
|
||||||
|
errorDiv.text(error.message);
|
||||||
|
window.lam.dialog.showMessage(dialogTitle,
|
||||||
|
buttonLabel,
|
||||||
|
'generic-webauthn-error'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
window.lam.webauthn.register(publicKey, successCallback, errorCallback);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
jQuery(document).ready(function() {
|
jQuery(document).ready(function() {
|
||||||
window.lam.gui.equalHeight();
|
window.lam.gui.equalHeight();
|
||||||
window.lam.form.autoTrim();
|
window.lam.form.autoTrim();
|
||||||
|
|
|
@ -342,7 +342,7 @@ class Ajax {
|
||||||
}
|
}
|
||||||
if ($action === 'delete') {
|
if ($action === 'delete') {
|
||||||
$credentialId = $_POST['credentialId'];
|
$credentialId = $_POST['credentialId'];
|
||||||
$this->manageWebauthnDevicesDelete($dn, $credentialId);
|
$this->manageWebauthnDevicesDelete($sessionDn, $credentialId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,18 @@
|
||||||
<?php
|
<?php
|
||||||
namespace LAM\TOOLS\WEBAUTHN;
|
namespace LAM\TOOLS\WEBAUTHN;
|
||||||
use \htmlButton;
|
use \htmlButton;
|
||||||
use htmlDiv;
|
use \htmlDiv;
|
||||||
use htmlGroup;
|
use \htmlGroup;
|
||||||
|
use htmlHiddenInput;
|
||||||
use \htmlOutputText;
|
use \htmlOutputText;
|
||||||
use \htmlResponsiveRow;
|
use \htmlResponsiveRow;
|
||||||
use \htmlResponsiveTable;
|
use \htmlResponsiveTable;
|
||||||
|
use \htmlSpacer;
|
||||||
use \htmlStatusMessage;
|
use \htmlStatusMessage;
|
||||||
use \htmlTitle;
|
use \htmlTitle;
|
||||||
use \LAM\LOGIN\WEBAUTHN\PublicKeyCredentialSourceRepositorySQLite;
|
use \LAM\LOGIN\WEBAUTHN\PublicKeyCredentialSourceRepositorySQLite;
|
||||||
|
use LAM\LOGIN\WEBAUTHN\WebauthnManager;
|
||||||
|
use Webauthn\PublicKeyCredentialCreationOptions;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
||||||
|
@ -48,6 +52,7 @@ include_once __DIR__ . '/../../lib/webauthn.inc';
|
||||||
// start session
|
// start session
|
||||||
startSecureSession();
|
startSecureSession();
|
||||||
enforceUserIsLoggedIn();
|
enforceUserIsLoggedIn();
|
||||||
|
validateSecurityToken();
|
||||||
|
|
||||||
checkIfToolIsActive('toolWebauthn');
|
checkIfToolIsActive('toolWebauthn');
|
||||||
|
|
||||||
|
@ -55,22 +60,43 @@ setlanguage();
|
||||||
|
|
||||||
include __DIR__ . '/../../lib/adminHeader.inc';
|
include __DIR__ . '/../../lib/adminHeader.inc';
|
||||||
echo '<div class="user-bright smallPaddingContent">';
|
echo '<div class="user-bright smallPaddingContent">';
|
||||||
echo "<form action=\"webauthn.php\" method=\"post\">\n";
|
echo "<form id='webauthnform' action=\"webauthn.php\" method=\"post\">\n";
|
||||||
$tabindex = 1;
|
$tabindex = 1;
|
||||||
$container = new htmlResponsiveRow();
|
$container = new htmlResponsiveRow();
|
||||||
|
|
||||||
$container->add(new htmlTitle(_("Webauthn devices")), 12);
|
$container->add(new htmlTitle(_("Webauthn devices")), 12);
|
||||||
|
|
||||||
|
$webauthnManager = new WebauthnManager();
|
||||||
|
|
||||||
$userDn = $_SESSION['ldap']->getUserName();
|
$userDn = $_SESSION['ldap']->getUserName();
|
||||||
$database = new PublicKeyCredentialSourceRepositorySQLite();
|
$database = new PublicKeyCredentialSourceRepositorySQLite();
|
||||||
$results = $database->searchDevices($userDn);
|
showRemoveMessage($container);
|
||||||
|
addNewDevice($container, $webauthnManager);
|
||||||
$container->addVerticalSpacer('0.5rem');
|
$container->addVerticalSpacer('0.5rem');
|
||||||
|
$container->add(new htmlHiddenInput('registrationData', ''), 12);
|
||||||
|
$errorMessageDiv = new htmlDiv('generic-webauthn-error', new htmlOutputText(''));
|
||||||
|
$errorMessageDiv->addDataAttribute('button', _('Ok'));
|
||||||
|
$errorMessageDiv->addDataAttribute('title', _('Webauthn failed'));
|
||||||
|
$container->add($errorMessageDiv, 12);
|
||||||
$buttonGroup = new htmlGroup();
|
$buttonGroup = new htmlGroup();
|
||||||
|
$registerButton = new htmlButton('register', _('Register new device'));
|
||||||
|
$registerButton->addDataAttribute('dn', $userDn);
|
||||||
|
$registerButton->addDataAttribute('sec_token_value', getSecurityTokenValue());
|
||||||
|
$registerButton->addDataAttribute('sec_token_name', getSecurityTokenName());
|
||||||
|
$registration = $webauthnManager->getRegistrationObject($userDn, false);
|
||||||
|
$registrationJson = json_encode($registration);
|
||||||
|
$_SESSION['webauthn_registration'] = $registrationJson;
|
||||||
|
$registerButton->addDataAttribute('publickey', $registrationJson);
|
||||||
|
$registerButton->setIconClass('createButton');
|
||||||
|
$registerButton->setOnClick('window.lam.webauthn.registerOwnDevice(event);');
|
||||||
|
$buttonGroup->addElement($registerButton);
|
||||||
|
$buttonGroup->addElement(new htmlSpacer('1rem', null));
|
||||||
$reloadButton = new htmlButton('reload', _('Reload'));
|
$reloadButton = new htmlButton('reload', _('Reload'));
|
||||||
$reloadButton->setIconClass('refreshButton');
|
$reloadButton->setIconClass('refreshButton');
|
||||||
$buttonGroup->addElement($reloadButton);
|
$buttonGroup->addElement($reloadButton);
|
||||||
$container->add($buttonGroup, 12);
|
$container->add($buttonGroup, 12);
|
||||||
$container->addVerticalSpacer('2rem');
|
$container->addVerticalSpacer('2rem');
|
||||||
|
$results = $database->searchDevices($userDn);
|
||||||
if (empty($results)) {
|
if (empty($results)) {
|
||||||
$container->add(new htmlStatusMessage('INFO', _('No devices found.')), 12);
|
$container->add(new htmlStatusMessage('INFO', _('No devices found.')), 12);
|
||||||
}
|
}
|
||||||
|
@ -107,6 +133,7 @@ $container->addVerticalSpacer('2rem');
|
||||||
$confirmationDiv = new htmlDiv('webauthnDeleteConfirm', new htmlOutputText(_('Do you really want to remove this device?')), array('hidden'));
|
$confirmationDiv = new htmlDiv('webauthnDeleteConfirm', new htmlOutputText(_('Do you really want to remove this device?')), array('hidden'));
|
||||||
$container->add($confirmationDiv, 12);
|
$container->add($confirmationDiv, 12);
|
||||||
|
|
||||||
|
addSecurityTokenToMetaHTML($container);
|
||||||
|
|
||||||
parseHtml(null, $container, array(), false, $tabindex, 'user');
|
parseHtml(null, $container, array(), false, $tabindex, 'user');
|
||||||
|
|
||||||
|
@ -114,4 +141,34 @@ echo '</form>';
|
||||||
echo '</div>';
|
echo '</div>';
|
||||||
include __DIR__ . '/../../lib/adminFooter.inc';
|
include __DIR__ . '/../../lib/adminFooter.inc';
|
||||||
|
|
||||||
?>
|
/**
|
||||||
|
* Checks if a new device should be registered and adds it.
|
||||||
|
*
|
||||||
|
* @param htmlResponsiveRow $container row
|
||||||
|
* @param WebauthnManager $webauthnManager webauthn manager
|
||||||
|
*/
|
||||||
|
function addNewDevice($container, $webauthnManager) {
|
||||||
|
if (empty($_POST['registrationData'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$registrationData = base64_decode($_POST['registrationData']);
|
||||||
|
$registrationObject = PublicKeyCredentialCreationOptions::createFromString($_SESSION['webauthn_registration']);
|
||||||
|
$success = $webauthnManager->storeNewRegistration($registrationObject, $registrationData);
|
||||||
|
if ($success) {
|
||||||
|
$container->add(new htmlStatusMessage('INFO', _('The device was registered.')), 12);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$container->add(new htmlStatusMessage('ERROR', _('The device failed to register.')), 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the message if a device was removed.
|
||||||
|
*
|
||||||
|
* @param htmlResponsiveRow $container row
|
||||||
|
*/
|
||||||
|
function showRemoveMessage($container) {
|
||||||
|
if (!empty($_POST['removed']) && ($_POST['removed'] === 'true')) {
|
||||||
|
$container->add(new htmlStatusMessage('INFO', _('The device was deleted.')), 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue