This commit is contained in:
Roland Gruber 2020-01-04 18:28:25 +01:00
parent ef9b3dd64e
commit 38addc429c
9 changed files with 630 additions and 277 deletions

View File

@ -655,6 +655,11 @@
<listitem>
<para><ulink url="https://duo.com/">Duo</ulink></para>
</listitem>
<listitem>
<para><ulink
url="https://webauthn.io/">Webauthn/FIDO2</ulink></para>
</listitem>
</itemizedlist>
<para>Configuration options:</para>
@ -752,6 +757,27 @@
</listitem>
</itemizedlist>
<para><emphasis role="bold">Webauthn/FIDO2</emphasis></para>
<para>Users will be asked to register a device during login if no
device is setup.</para>
<itemizedlist>
<listitem>
<para>Domain: Please enter the WebAuthn domain. This is the public
domain of the web server (e.g. "example.com"). Do not include
protocol or port. Browsers will reject authentication if the
domain does not match the web server domain.</para>
</listitem>
<listitem>
<para>Optional: By default LAM will enforce to use a 2FA device
and reject users that do not setup one. You can set this check to
optional. But if a user has setup a device then this will always
be required.</para>
</listitem>
</itemizedlist>
<screenshot>
<mediaobject>
<imageobject>

View File

@ -179,6 +179,8 @@ $helpArray = array (
"Text" => _("Here you can input simple filter expressions (e.g. 'value' or 'v*'). The filter is case-insensitive.")),
"251" => array ("Headline" => _("Remote server"),
"Text" => _("Please enter the syslog remote server in format \"server:port\".")),
"252" => array ("Headline" => _("User DN"),
"Text" => _("Please enter a part of the user's DN to search for registered devices.")),
"260" => array ("Headline" => _("Additional LDAP filter"),
"Text" => _('Use this to enter an additional LDAP filter (e.g. "(cn!=admin)") to reduce the number of visible elements for this account type.')
. ' ' . _('You can use the wildcard @@LOGIN_DN@@ which will be substituted with the DN of the user who is currently logged in to LAM.')

View File

@ -1098,10 +1098,10 @@ class htmlButton extends htmlElement {
}
$id = ' id="btn_' . preg_replace('/[^a-zA-Z0-9_-]/', '', $this->name) . '"';
if ($this->isImageButton) {
echo '<input type="submit" ' . $id . ' value=" "' . $name . $onClick . $fieldTabIndex . $style . $class . $title . $disabled . '>';
echo '<input type="submit" ' . $id . ' value=" "' . $name . $onClick . $fieldTabIndex . $style . $class . $title . $disabled . $this->getDataAttributesAsString() . '>';
}
else {
echo '<button' . $id . $name . $fieldTabIndex . $type . $onClick . $style . $class . $title . $disabled . '>' . $this->value . '</button>';
echo '<button' . $id . $name . $fieldTabIndex . $type . $onClick . $style . $class . $title . $disabled . $this->getDataAttributesAsString() . '>' . $this->value . '</button>';
// text buttons get JQuery style
$icon = '';
if ($this->iconClass != null) {

View File

@ -523,6 +523,62 @@ class PublicKeyCredentialSourceRepositorySQLite implements PublicKeyCredentialSo
return 'sqlite:' . $fileName;
}
/**
* Returns if there are any credentials in the database.
*
* @return bool at least one credential in the database
*/
public function hasRegisteredCredentials() {
$pdo = $this->getPDO();
$statement = $pdo->prepare('select count(*) from ' . self::TABLE_NAME);
$statement->execute();
$results = $statement->fetchAll();
return ($results[0][0] > 0);
}
/**
* Performs a full-text search on the user names and returns all devices found.
*
* @param string $searchTerm search term for user field
* @return array list of devices array('dn' => ..., 'credentialId' => ..., 'lastUseTime' => ..., 'registrationTime' => ...)
*/
public function searchDevices(string $searchTerm) {
$pdo = $this->getPDO();
$statement = $pdo->prepare('select * from ' . self::TABLE_NAME . ' where userId like :searchTerm');
$statement->execute(array(
':searchTerm' => '%' . $searchTerm . '%'
));
$results = $statement->fetchAll();
$devices = array();
foreach ($results as $result) {
$devices[] = array(
'dn' => $result['userId'],
'credentialId' => $result['credentialId'],
'lastUseTime' => $result['lastUseTime'],
'registrationTime' => $result['registrationTime']
);
}
return $devices;
}
/**
* Deletes a single device from the database.
*
* @param string $dn user DN
* @param string $credentialId credential id
* @return bool deletion was ok
*/
public function deleteDevice(string $dn, string $credentialId) {
logNewMessage(LOG_NOTICE, 'Delete webauthn device ' . $credentialId . ' of ' . $dn);
$pdo = $this->getPDO();
$statement = $pdo->prepare('delete from ' . self::TABLE_NAME . ' where userId = :userId and credentialId = :credentialId');
$result = $statement->execute(array(
':userId' => $dn,
':credentialId' => $credentialId
));
return $statement->rowCount() > 0;
}
/**
* Returns the PDO.
*

View File

@ -547,6 +547,9 @@ input.markOk {
text-decoration: line-through;
}
div.lam-webauthn-results {
max-height: 10rem;
}
/**
* table style for delete.php

View File

@ -1,5 +1,6 @@
<?php
namespace LAM\CONFIG;
use \LAMCfgMain;
use \htmlTable;
use \htmlTitle;
@ -22,10 +23,11 @@ use \htmlResponsiveInputCheckbox;
use \htmlResponsiveInputField;
use \htmlDiv;
use \htmlHiddenInput;
/*
This code is part of LDAP Account Manager (http://www.ldap-account-manager.org/)
Copyright (C) 2003 - 2019 Roland Gruber
Copyright (C) 2003 - 2020 Roland Gruber
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@ -95,8 +97,7 @@ if (isset($_POST['submitFormData'])) {
$cfg->setPassword($_POST['masterpassword']);
$msg = _("New master password set successfully.");
unset($_SESSION["mainconf_password"]);
}
else {
} else {
$errors[] = _("Master passwords are different or empty!");
}
}
@ -126,8 +127,7 @@ if (isset($_POST['submitFormData'])) {
}
}
$allowedHosts = implode(",", $allowedHostsList);
}
else {
} else {
$allowedHosts = "";
}
$cfg->allowedHosts = $allowedHosts;
@ -150,8 +150,7 @@ if (isset($_POST['submitFormData'])) {
}
}
$allowedHostsSelfService = implode(",", $allowedHostsSelfServiceList);
}
else {
} else {
$allowedHostsSelfService = "";
}
$cfg->allowedHostsSelfService = $allowedHostsSelfService;
@ -169,22 +168,18 @@ if (isset($_POST['submitFormData'])) {
// set log destination
if ($_POST['logDestination'] == "none") {
$cfg->logDestination = "NONE";
}
elseif ($_POST['logDestination'] == "syslog") {
} elseif ($_POST['logDestination'] == "syslog") {
$cfg->logDestination = "SYSLOG";
}
elseif ($_POST['logDestination'] == "remote") {
} elseif ($_POST['logDestination'] == "remote") {
$cfg->logDestination = "REMOTE:" . $_POST['logRemote'];
$remoteParts = explode(':', $_POST['logRemote']);
if ((sizeof($remoteParts) !== 2) || !get_preg($remoteParts[0], 'DNSname') || !get_preg($remoteParts[1], 'digit')) {
$errors[] = _("Please enter a valid remote server in format \"server:port\".");
}
}
else {
} else {
if (isset($_POST['logFile']) && ($_POST['logFile'] != "") && preg_match("/^[a-z0-9\\/\\\\:\\._-]+$/i", $_POST['logFile'])) {
$cfg->logDestination = $_POST['logFile'];
}
else {
} else {
$errors[] = _("The log file is empty or contains invalid characters! Valid characters are: a-z, A-Z, 0-9, /, \\, ., :, _ and -.");
}
}
@ -207,16 +202,14 @@ if (isset($_POST['submitFormData'])) {
if (isset($_POST['sslCaCertUpload'])) {
if (!isset($_FILES['sslCaCert']) || ($_FILES['sslCaCert']['size'] == 0)) {
$errors[] = _('No file selected.');
}
else {
} else {
$handle = fopen($_FILES['sslCaCert']['tmp_name'], "r");
$data = fread($handle, 10000000);
fclose($handle);
$sslReturn = $cfg->uploadSSLCaCert($data);
if ($sslReturn !== true) {
$errors[] = $sslReturn;
}
else {
} else {
$messages[] = _('You might need to restart your webserver for changes to take effect.');
}
}
@ -237,12 +230,10 @@ if (isset($_POST['submitFormData'])) {
$messages[] = _('Imported certificate from server.');
$messages[] = _('You might need to restart your webserver for changes to take effect.');
$cfg->uploadSSLCaCert($pemResult);
}
else {
} else {
$errors[] = _('Unable to import server certificate. Please use the upload function.');
}
}
else {
} else {
$errors[] = _('Invalid server name. Please enter "server" or "server:port".');
}
}
@ -428,12 +419,10 @@ $destinationRemote = '';
if ($cfg->logDestination == 'NONE') {
$destinationSelected = 'none';
$destinationPath = '';
}
elseif ($cfg->logDestination == 'SYSLOG') {
} elseif ($cfg->logDestination == 'SYSLOG') {
$destinationSelected = 'syslog';
$destinationPath = '';
}
elseif (strpos($cfg->logDestination, 'REMOTE') === 0) {
} elseif (strpos($cfg->logDestination, 'REMOTE') === 0) {
$destinationSelected = 'remote';
$remoteParts = explode(':', $cfg->logDestination, 2);
$destinationRemote = empty($remoteParts[1]) ? '' : $remoteParts[1];
@ -476,6 +465,24 @@ if (isLAMProVersion()) {
}
$row->addVerticalSpacer('3rem');
// webauthn management
if ((version_compare(phpversion(), '7.2.0') >= 0)
&& extension_loaded('PDO')
&& in_array('sqlite', \PDO::getAvailableDrivers())) {
include_once __DIR__ . '/../../lib/webauthn.inc';
$database = new \LAM\LOGIN\WEBAUTHN\PublicKeyCredentialSourceRepositorySQLite();
if ($database->hasRegisteredCredentials()) {
$row->add(new htmlSubTitle(_('Webauthn devices')), 12);
$row->add(new htmlResponsiveInputField(_('User DN'), 'webauthn_userDN', null, '252'), 12);
$row->addVerticalSpacer('0.5rem');
$row->add(new htmlButton('webauthn_search', _('Search')), 12, 12, 12, 'text-center');
$resultDiv = new htmlDiv('webauthn_results', new htmlOutputText(''), array('lam-webauthn-results'));
addSecurityTokenToSession(false);
$resultDiv->addDataAttribute('sec_token_value', getSecurityTokenValue());
$row->add($resultDiv, 12);
}
}
// change master password
$row->add(new htmlSubTitle(_("Change master password")), 12);
$pwd1 = new htmlResponsiveInputField(_("New master password"), 'masterpassword', '', '235');
@ -522,7 +529,6 @@ function formatSSLTimestamp($time) {
}
?>
</form>

View File

@ -1522,6 +1522,96 @@ window.lam.webauthn.arrayToBase64String = function(input) {
return btoa(String.fromCharCode(...input));
}
/**
* Sets up the device management on the main configuration page.
*/
window.lam.webauthn.setupDeviceManagement = function() {
const searchButton = jQuery('#btn_webauthn_search');
if (searchButton) {
searchButton.click(window.lam.webauthn.searchDevices);
}
}
/**
* Searches for devices via Ajax call.
*
* @param event button click event
* @returns {boolean} false
*/
window.lam.webauthn.searchDevices = function(event) {
if (event !== null) {
event.preventDefault();
}
const resultDiv = jQuery('#webauthn_results');
const tokenValue = resultDiv.data('sec_token_value');
const searchData = jQuery('#webauthn_userDN').val();
const data = {
action: 'search',
jsonInput: '',
sec_token: tokenValue,
searchTerm: searchData
};
jQuery.ajax({
url: '../misc/ajax.php?function=webauthnDevices',
method: 'POST',
data: data
})
.done(function(jsonData) {
resultDiv.html(jsonData.content);
window.lam.webauthn.addDeviceActionListeners();
})
.fail(function() {
console.log('Webauthn search failed');
});
return false;
}
/**
* Adds listeners to the device action buttons.
*/
window.lam.webauthn.addDeviceActionListeners = function() {
const inputs = jQuery('.webauthn-delete');
inputs.each(function() {
jQuery(this).click(function(event) {
window.lam.webauthn.removeDevice(event);
});
});
}
/**
* Removes a webauthn device.
*
* @param element button
*/
window.lam.webauthn.removeDevice = function(event) {
event.preventDefault();
const element = jQuery(event.target);
const dn = element.data('dn');
const credential = element.data('credential');
const resultDiv = jQuery('#webauthn_results');
const tokenValue = resultDiv.data('sec_token_value');
const searchData = jQuery('#webauthn_userDN').val();
const data = {
action: 'delete',
jsonInput: '',
sec_token: tokenValue,
dn: dn,
credentialId: credential
};
jQuery.ajax({
url: '../misc/ajax.php?function=webauthnDevices',
method: 'POST',
data: data
})
.done(function(jsonData) {
resultDiv.html(jsonData.content);
})
.fail(function() {
console.log('Webauthn device deletion failed');
});
return false;
}
jQuery(document).ready(function() {
window.lam.gui.equalHeight();
window.lam.form.autoTrim();
@ -1533,6 +1623,7 @@ jQuery(document).ready(function() {
window.lam.html.activateLightboxes();
window.lam.html.preventEnter();
window.lam.dynamicSelect.activate();
window.lam.webauthn.setupDeviceManagement();
});
/**

View File

@ -1,5 +1,7 @@
<?php
namespace LAM\AJAX;
use htmlResponsiveTable;
use htmlStatusMessage;
use \LAM\TOOLS\IMPORT_EXPORT\Importer;
use \LAM\TOOLS\IMPORT_EXPORT\Exporter;
use \LAM\TYPES\TypeManager;
@ -7,7 +9,8 @@ use \htmlResponsiveRow;
use \htmlLink;
use \htmlOutputText;
use \htmlButton;
use LAM\LOGIN\WEBAUTHN\WebauthnManager;
use \LAM\LOGIN\WEBAUTHN\WebauthnManager;
use \LAMCfgMain;
/*
@ -108,6 +111,11 @@ class Ajax {
$this->manageWebauthn($isSelfService);
die();
}
if ($function === 'webauthnDevices') {
$this->enforceUserIsLoggedInToMainConfiguration();
$this->manageWebauthnDevices();
die();
}
enforceUserIsLoggedIn();
if ($function == 'passwordChange') {
$this->managePasswordChange($jsonInput);
@ -219,6 +227,102 @@ class Ajax {
die();
}
/**
* Webauthn device management.
*/
private function manageWebauthnDevices() {
$action = $_POST['action'];
if ($action === 'search') {
$searchTerm = $_POST['searchTerm'];
if (!empty($searchTerm)) {
$this->manageWebauthnDevicesSearch($searchTerm);
}
}
elseif ($action === 'delete') {
$dn = $_POST['dn'];
$credentialId = $_POST['credentialId'];
if (!empty($dn) && !empty($credentialId)) {
$this->manageWebauthnDevicesDelete($dn, $credentialId);
}
}
}
/**
* Searches for webauthn devices and prints the results as html.
*
* @param string $searchTerm search term
*/
private function manageWebauthnDevicesSearch($searchTerm) {
include_once __DIR__ . '/../../lib/webauthn.inc';
$database = new \LAM\LOGIN\WEBAUTHN\PublicKeyCredentialSourceRepositorySQLite();
$results = $database->searchDevices($searchTerm);
$row = new htmlResponsiveRow();
$row->addVerticalSpacer('0.5rem');
if (empty($results)) {
$row->add(new htmlStatusMessage('INFO', _('No devices found.')), 12);
}
else {
$titles = array(
_('User'),
_('Registration'),
_('Last use'),
_('Delete')
);
$data = array();
$id = 0;
foreach ($results as $result) {
$delButton = new htmlButton('deleteDevice' . $id, 'delete.png', true);
$delButton->addDataAttribute('credential', $result['credentialId']);
$delButton->addDataAttribute('dn', $result['dn']);
$delButton->setCSSClasses(array('webauthn-delete'));
$data[] = array(
new htmlOutputText($result['dn']),
new htmlOutputText(date('Y-m-d H:i:s', $result['registrationTime'])),
new htmlOutputText(date('Y-m-d H:i:s', $result['lastUseTime'])),
$delButton
);
$id++;
}
$table = new htmlResponsiveTable($titles, $data);
$row->add($table, 12);
}
$row->addVerticalSpacer('2rem');
$tabindex = 10000;
ob_start();
$row->generateHTML('none', array(), array(), false, $tabindex, null);
$content = ob_get_contents();
ob_end_clean();
echo json_encode(array('content' => $content));
}
/**
* Deletes a webauthn device.
*
* @param string $dn user DN
* @param string $credentialId base64 encoded credential id
*/
private function manageWebauthnDevicesDelete($dn, $credentialId) {
include_once __DIR__ . '/../../lib/webauthn.inc';
$database = new \LAM\LOGIN\WEBAUTHN\PublicKeyCredentialSourceRepositorySQLite();
$success = $database->deleteDevice($dn, $credentialId);
if ($success) {
$message = new htmlStatusMessage('INFO', _('The device was deleted.'));
}
else {
$message = new htmlStatusMessage('ERROR', _('The device was not found.'));
}
$row = new htmlResponsiveRow();
$row->addVerticalSpacer('0.5rem');
$row->add($message, 12);
$row->addVerticalSpacer('2rem');
ob_start();
$tabindex = 50000;
$row->generateHTML('none', array(), array(), true, $tabindex, null);
$content = ob_get_contents();
ob_end_clean();
echo json_encode(array('content' => $content));
}
/**
* Handles DN selection fields.
*
@ -341,6 +445,23 @@ class Ajax {
return $dnList;
}
/**
* Checks if the user entered the configuration master password.
* Dies if password is not set.
*/
private function enforceUserIsLoggedInToMainConfiguration() {
if (!isset($_SESSION['cfgMain'])) {
$cfg = new LAMCfgMain();
}
else {
$cfg = $_SESSION['cfgMain'];
}
if (isset($_SESSION["mainconf_password"]) && ($cfg->checkPassword($_SESSION["mainconf_password"]))) {
return;
}
die();
}
}

View File

@ -120,5 +120,53 @@ class PublicKeyCredentialSourceRepositorySQLiteTest extends TestCase {
));
}
public function test_hasRegisteredCredentials() {
$this->assertFalse($this->database->hasRegisteredCredentials());
$source1 = new PublicKeyCredentialSource(
"id1",
PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY,
array(),
"atype",
new CertificateTrustPath(array('x5c' => 'test')),
\Ramsey\Uuid\Uuid::uuid1(),
"p1",
"uh1",
1);
$this->database->saveCredentialSource($source1);
$this->assertTrue($this->database->hasRegisteredCredentials());
}
public function test_searchDevices() {
$source1 = new PublicKeyCredentialSource(
"id1",
PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY,
array(),
"atype",
new CertificateTrustPath(array('x5c' => 'test')),
\Ramsey\Uuid\Uuid::uuid1(),
"p1",
"uh1",
1);
$this->database->saveCredentialSource($source1);
$this->assertNotEmpty($this->database->searchDevices('h1'));
$this->assertEmpty($this->database->searchDevices('h2'));
}
public function test_deleteDevice() {
$source1 = new PublicKeyCredentialSource(
"id1",
PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY,
array(),
"atype",
new CertificateTrustPath(array('x5c' => 'test')),
\Ramsey\Uuid\Uuid::uuid1(),
"p1",
"uh1",
1);
$this->database->saveCredentialSource($source1);
$this->assertTrue($this->database->deleteDevice('uh1', base64_encode('id1')));
$this->assertFalse($this->database->deleteDevice('uh1', base64_encode('id2')));
}
}