From 38addc429cac7568e85954a81ebe4b7518b78d6a Mon Sep 17 00:00:00 2001 From: Roland Gruber Date: Sat, 4 Jan 2020 18:28:25 +0100 Subject: [PATCH] webauthn --- .../manual-sources/chapter-configuration.xml | 26 + lam/help/help.inc | 2 + lam/lib/html.inc | 4 +- lam/lib/webauthn.inc | 56 ++ lam/style/500_layout.css | 3 + lam/templates/config/mainmanage.php | 554 +++++++++--------- lam/templates/lib/500_lam.js | 91 +++ lam/templates/misc/ajax.php | 123 +++- lam/tests/lib/webauthnDbTest.php | 48 ++ 9 files changed, 630 insertions(+), 277 deletions(-) diff --git a/lam/docs/manual-sources/chapter-configuration.xml b/lam/docs/manual-sources/chapter-configuration.xml index 8b69b1e4..d4d77ec1 100644 --- a/lam/docs/manual-sources/chapter-configuration.xml +++ b/lam/docs/manual-sources/chapter-configuration.xml @@ -655,6 +655,11 @@ Duo + + + Webauthn/FIDO2 + Configuration options: @@ -752,6 +757,27 @@ + Webauthn/FIDO2 + + Users will be asked to register a device during login if no + device is setup. + + + + 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. + + + + 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. + + + diff --git a/lam/help/help.inc b/lam/help/help.inc index f3cc3c99..7f9692aa 100644 --- a/lam/help/help.inc +++ b/lam/help/help.inc @@ -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.') diff --git a/lam/lib/html.inc b/lam/lib/html.inc index 14410395..52daa4db 100644 --- a/lam/lib/html.inc +++ b/lam/lib/html.inc @@ -1098,10 +1098,10 @@ class htmlButton extends htmlElement { } $id = ' id="btn_' . preg_replace('/[^a-zA-Z0-9_-]/', '', $this->name) . '"'; if ($this->isImageButton) { - echo ''; + echo 'getDataAttributesAsString() . '>'; } else { - echo '' . $this->value . ''; + echo 'getDataAttributesAsString() . '>' . $this->value . ''; // text buttons get JQuery style $icon = ''; if ($this->iconClass != null) { diff --git a/lam/lib/webauthn.inc b/lam/lib/webauthn.inc index d7402daf..9b1fabac 100644 --- a/lam/lib/webauthn.inc +++ b/lam/lib/webauthn.inc @@ -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. * diff --git a/lam/style/500_layout.css b/lam/style/500_layout.css index 32e07b6a..86a508ea 100644 --- a/lam/style/500_layout.css +++ b/lam/style/500_layout.css @@ -547,6 +547,9 @@ input.markOk { text-decoration: line-through; } +div.lam-webauthn-results { + max-height: 10rem; +} /** * table style for delete.php diff --git a/lam/templates/config/mainmanage.php b/lam/templates/config/mainmanage.php index 9ebab21c..1ef2836c 100644 --- a/lam/templates/config/mainmanage.php +++ b/lam/templates/config/mainmanage.php @@ -1,5 +1,6 @@ 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".'); } } @@ -270,264 +261,279 @@ if (isset($_POST['submitFormData'])) { echo $_SESSION['header']; printHeaderContents(_("Edit general settings"), '../..'); ?> - - - - - - -
- -
-
- -
+ + + + + + +
+ +
+
+ + -add(new htmlTitle(_('General settings')), 12); + $row = new htmlResponsiveRow(); + $row->add(new htmlTitle(_('General settings')), 12); -// print messages -for ($i = 0; $i < sizeof($errors); $i++) { - $row->add(new htmlStatusMessage("ERROR", $errors[$i]), 12); -} -for ($i = 0; $i < sizeof($messages); $i++) { - $row->add(new htmlStatusMessage("INFO", $messages[$i]), 12); -} - -// check if config file is writable -if (!$cfg->isWritable()) { - $row->add(new htmlStatusMessage('WARN', 'The config file is not writable.', 'Your changes cannot be saved until you make the file writable for the webserver user.'), 12); -} - -// license -if (isLAMProVersion()) { - $row->add(new htmlSubTitle(_('Licence')), 12); - $row->add(new htmlResponsiveInputTextarea('license', implode("\n", $cfg->getLicenseLines()), null, 10, _('Licence'), '287'), 12); - - $row->add(new htmlSpacer(null, '1rem'), true); -} - -// security settings -$row->add(new htmlSubTitle(_("Security settings")), 12); -$options = array(5, 10, 20, 30, 60, 90, 120, 240); -$row->add(new htmlResponsiveSelect('sessionTimeout', $options, array($cfg->sessionTimeout), _("Session timeout"), '238'), 12); -$row->add(new htmlResponsiveInputTextarea('allowedHosts', implode("\n", explode(",", $cfg->allowedHosts)), null, '7', _("Allowed hosts"), '241'), 12); -if (isLAMProVersion()) { - $row->add(new htmlResponsiveInputTextarea('allowedHostsSelfService', implode("\n", explode(",", $cfg->allowedHostsSelfService)), null, '7', _("Allowed hosts (self service)"), '241'), 12); -} -$encryptSession = ($cfg->encryptSession === 'true'); -$encryptSessionBox = new htmlResponsiveInputCheckbox('encryptSession', $encryptSession, _('Encrypt session'), '245'); -$encryptSessionBox->setIsEnabled(function_exists('openssl_random_pseudo_bytes')); -$row->add($encryptSessionBox, 12); -// SSL certificate -$row->addVerticalSpacer('1rem'); -$row->addLabel(new htmlOutputText(_('SSL certificates'))); -$sslMethod = _('use system certificates'); -$sslFileName = $cfg->getSSLCaCertTempFileName(); -if ($sslFileName != null) { - $sslMethod = _('use custom CA certificates'); -} -$sslDelSaveGroup = new htmlGroup(); -$sslDelSaveGroup->addElement(new htmlOutputText($sslMethod)); -$sslDelSaveGroup->addElement(new htmlSpacer('5px', null)); -// delete+download button -if ($sslFileName != null) { - $sslDownloadBtn = new htmlLink('', '../../tmp/' . $sslFileName, '../../graphics/save.png'); - $sslDownloadBtn->setTargetWindow('_blank'); - $sslDownloadBtn->setTitle(_('Download CA certificates')); - $sslDelSaveGroup->addElement($sslDownloadBtn); - $sslDeleteBtn = new htmlButton('sslCaCertDelete', 'delete.png', true); - $sslDeleteBtn->setTitle(_('Delete all CA certificates')); - $sslDelSaveGroup->addElement($sslDeleteBtn); -} -$sslDelSaveGroup->addElement(new htmlHelpLink('204')); -$row->addField($sslDelSaveGroup); -$row->addLabel(new htmlInputFileUpload('sslCaCert')); -$sslUploadBtn = new htmlButton('sslCaCertUpload', _('Upload')); -$sslUploadBtn->setIconClass('upButton'); -$sslUploadBtn->setTitle(_('Upload CA certificate in DER/PEM format.')); -$row->addField($sslUploadBtn); -if (function_exists('stream_socket_client') && function_exists('stream_context_get_params')) { - $sslImportServerUrl = !empty($_POST['serverurl']) ? $_POST['serverurl'] : 'ldaps://'; - $serverUrlUpload = new htmlInputField('serverurl', $sslImportServerUrl); - $row->addLabel($serverUrlUpload); - $sslImportBtn = new htmlButton('sslCaCertImport', _('Import from server')); - $sslImportBtn->setIconClass('downButton'); - $sslImportBtn->setTitle(_('Imports the certificate directly from your LDAP server.')); - $row->addField($sslImportBtn); -} - -$sslCerts = $cfg->getSSLCaCertificates(); -if (sizeof($sslCerts) > 0) { - $certsTitles = array(_('Common name'), _('Valid to'), _('Serial number'), _('Delete')); - $certsData = array(); - for ($i = 0; $i < sizeof($sslCerts); $i++) { - $serial = isset($sslCerts[$i]['serialNumber']) ? $sslCerts[$i]['serialNumber'] : ''; - $validTo = isset($sslCerts[$i]['validTo_time_t']) ? $sslCerts[$i]['validTo_time_t'] : ''; - $cn = isset($sslCerts[$i]['subject']['CN']) ? $sslCerts[$i]['subject']['CN'] : ''; - $delBtn = new htmlButton('deleteCert_' . $i, 'delete.png', true); - $certsData[] = array( - new htmlOutputText($cn), - new htmlOutputText($validTo), - new htmlOutputText($serial), - $delBtn - ); + // print messages + for ($i = 0; $i < sizeof($errors); $i++) { + $row->add(new htmlStatusMessage("ERROR", $errors[$i]), 12); + } + for ($i = 0; $i < sizeof($messages); $i++) { + $row->add(new htmlStatusMessage("INFO", $messages[$i]), 12); } - $certsTable = new \htmlResponsiveTable($certsTitles, $certsData); - $row->add($certsTable, 12); -} -// password policy -$row->add(new htmlSubTitle(_("Password policy")), 12); -$options20 = array(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20); -$options4 = array(0, 1, 2, 3, 4); -$row->add(new htmlResponsiveSelect('passwordMinLength', $options20, array($cfg->passwordMinLength), _('Minimum password length'), '242'), 12); -$row->addVerticalSpacer('1rem'); -$row->add(new htmlResponsiveSelect('passwordMinLower', $options20, array($cfg->passwordMinLower), _('Minimum lowercase characters'), '242'), 12); -$row->add(new htmlResponsiveSelect('passwordMinUpper', $options20, array($cfg->passwordMinUpper), _('Minimum uppercase characters'), '242'), 12); -$row->add(new htmlResponsiveSelect('passwordMinNumeric', $options20, array($cfg->passwordMinNumeric), _('Minimum numeric characters'), '242'), 12); -$row->add(new htmlResponsiveSelect('passwordMinSymbol', $options20, array($cfg->passwordMinSymbol), _('Minimum symbolic characters'), '242'), 12); -$row->add(new htmlResponsiveSelect('passwordMinClasses', $options4, array($cfg->passwordMinClasses), _('Minimum character classes'), '242'), 12); -$row->addVerticalSpacer('1rem'); -$rulesCountOptions = array(_('all') => '-1', '3' => '3', '4' => '4'); -$rulesCountSelect = new htmlResponsiveSelect('passwordRulesCount', $rulesCountOptions, array($cfg->checkedRulesCount), _('Number of rules that must match'), '246'); -$rulesCountSelect->setHasDescriptiveElements(true); -$row->add($rulesCountSelect, 12); -$passwordMustNotContainUser = ($cfg->passwordMustNotContainUser === 'true'); -$row->add(new htmlResponsiveInputCheckbox('passwordMustNotContainUser',$passwordMustNotContainUser , _('Password must not contain user name'), '247'), 12); -$passwordMustNotContain3Chars = ($cfg->passwordMustNotContain3Chars === 'true'); -$row->add(new htmlResponsiveInputCheckbox('passwordMustNotContain3Chars', $passwordMustNotContain3Chars, _('Password must not contain part of user/first/last name'), '248'), 12); -if (function_exists('curl_init')) { + // check if config file is writable + if (!$cfg->isWritable()) { + $row->add(new htmlStatusMessage('WARN', 'The config file is not writable.', 'Your changes cannot be saved until you make the file writable for the webserver user.'), 12); + } + + // license + if (isLAMProVersion()) { + $row->add(new htmlSubTitle(_('Licence')), 12); + $row->add(new htmlResponsiveInputTextarea('license', implode("\n", $cfg->getLicenseLines()), null, 10, _('Licence'), '287'), 12); + + $row->add(new htmlSpacer(null, '1rem'), true); + } + + // security settings + $row->add(new htmlSubTitle(_("Security settings")), 12); + $options = array(5, 10, 20, 30, 60, 90, 120, 240); + $row->add(new htmlResponsiveSelect('sessionTimeout', $options, array($cfg->sessionTimeout), _("Session timeout"), '238'), 12); + $row->add(new htmlResponsiveInputTextarea('allowedHosts', implode("\n", explode(",", $cfg->allowedHosts)), null, '7', _("Allowed hosts"), '241'), 12); + if (isLAMProVersion()) { + $row->add(new htmlResponsiveInputTextarea('allowedHostsSelfService', implode("\n", explode(",", $cfg->allowedHostsSelfService)), null, '7', _("Allowed hosts (self service)"), '241'), 12); + } + $encryptSession = ($cfg->encryptSession === 'true'); + $encryptSessionBox = new htmlResponsiveInputCheckbox('encryptSession', $encryptSession, _('Encrypt session'), '245'); + $encryptSessionBox->setIsEnabled(function_exists('openssl_random_pseudo_bytes')); + $row->add($encryptSessionBox, 12); + // SSL certificate $row->addVerticalSpacer('1rem'); - $row->add(new htmlResponsiveInputField(_('External password check'), 'externalPwdCheckUrl', $cfg->externalPwdCheckUrl, '249'), 12); -} - -// logging -$row->add(new htmlSubTitle(_("Logging")), 12); -$levelOptions = array(_("Debug") => LOG_DEBUG, _("Notice") => LOG_NOTICE, _("Warning") => LOG_WARNING, _("Error") => LOG_ERR); -$levelSelect = new htmlResponsiveSelect('logLevel', $levelOptions, array($cfg->logLevel), _("Log level"), '239'); -$levelSelect->setHasDescriptiveElements(true); -$row->add($levelSelect, 12); -$destinationOptions = array( - _("No logging") => "none", - _("System logging") => "syslog", - _("File") => 'file', - _("Remote") => 'remote', -); -$destinationSelected = 'file'; -$destinationPath = $cfg->logDestination; -$destinationRemote = ''; -if ($cfg->logDestination == 'NONE') { - $destinationSelected = 'none'; - $destinationPath = ''; -} -elseif ($cfg->logDestination == 'SYSLOG') { - $destinationSelected = 'syslog'; - $destinationPath = ''; -} -elseif (strpos($cfg->logDestination, 'REMOTE') === 0) { - $destinationSelected = 'remote'; - $remoteParts = explode(':', $cfg->logDestination, 2); - $destinationRemote = empty($remoteParts[1]) ? '' : $remoteParts[1]; - $destinationPath = ''; -} -$logDestinationSelect = new htmlResponsiveSelect('logDestination', $destinationOptions, array($destinationSelected), _("Log destination"), '240'); -$logDestinationSelect->setTableRowsToHide(array( - 'none' => array('logFile', 'logRemote'), - 'syslog' => array('logFile', 'logRemote'), - 'remote' => array('logFile'), - 'file' => array('logRemote'), -)); -$logDestinationSelect->setTableRowsToShow(array( - 'file' => array('logFile'), - 'remote' => array('logRemote'), -)); -$logDestinationSelect->setHasDescriptiveElements(true); -$row->add($logDestinationSelect, 12); -$row->add(new htmlResponsiveInputField(_('File'), 'logFile', $destinationPath), 12); -$row->add(new htmlResponsiveInputField(_('Remote server'), 'logRemote', $destinationRemote, '251'), 12); -$errorLogOptions = array( - _('PHP system setting') => LAMCfgMain::ERROR_REPORTING_SYSTEM, - _('default') => LAMCfgMain::ERROR_REPORTING_DEFAULT, - _('all') => LAMCfgMain::ERROR_REPORTING_ALL -); -$errorLogSelect = new htmlResponsiveSelect('errorReporting', $errorLogOptions, array($cfg->errorReporting), _('PHP error reporting'), '244'); -$errorLogSelect->setHasDescriptiveElements(true); -$row->add($errorLogSelect, 12); - -// additional options -if (isLAMProVersion()) { - $row->add(new htmlSubTitle(_('Additional options')), 12); - $mailEOLOptions = array( - _('Default (\r\n)') => 'default', - _('Non-standard (\n)') => 'unix' - ); - $mailEOLSelect = new htmlResponsiveSelect('mailEOL', $mailEOLOptions, array($cfg->mailEOL), _('Email format'), '243'); - $mailEOLSelect->setHasDescriptiveElements(true); - $row->add($mailEOLSelect, 12); -} -$row->addVerticalSpacer('3rem'); - -// change master password -$row->add(new htmlSubTitle(_("Change master password")), 12); -$pwd1 = new htmlResponsiveInputField(_("New master password"), 'masterpassword', '', '235'); -$pwd1->setIsPassword(true, false, true); -$row->add($pwd1, 12); -$pwd2 = new htmlResponsiveInputField(_("Reenter password"), 'masterpassword2', ''); -$pwd2->setIsPassword(true, false, true); -$pwd2->setSameValueFieldID('masterpassword'); -$row->add($pwd2, 12); -$row->addVerticalSpacer('3rem'); - -// buttons -if ($cfg->isWritable()) { - $buttonTable = new htmlTable(); - $buttonTable->addElement(new htmlButton('submit', _("Ok"))); - $buttonTable->addElement(new htmlSpacer('1rem', null)); - $buttonTable->addElement(new htmlButton('cancel', _("Cancel"))); - $row->add($buttonTable, 12); - $row->add(new htmlHiddenInput('submitFormData', '1'), 12); -} - -$box = new htmlDiv(null, $row); -$box->setCSSClasses(array('ui-corner-all', 'roundedShadowBox')); -parseHtml(null, $box, array(), false, $tabindex, 'user'); - - -/** - * Formats an LDAP time string (e.g. from createTimestamp). - * - * @param String $time LDAP time value - * @return String formated time - */ -function formatSSLTimestamp($time) { - if (!empty($time)) { - $timeZone = 'UTC'; - $sysTimeZone = @date_default_timezone_get(); - if (!empty($sysTimeZone)) { - $timeZone = $sysTimeZone; - } - $date = new DateTime('@' . $time, new DateTimeZone($timeZone)); - return $date->format('d.m.Y'); + $row->addLabel(new htmlOutputText(_('SSL certificates'))); + $sslMethod = _('use system certificates'); + $sslFileName = $cfg->getSSLCaCertTempFileName(); + if ($sslFileName != null) { + $sslMethod = _('use custom CA certificates'); } - return ''; -} + $sslDelSaveGroup = new htmlGroup(); + $sslDelSaveGroup->addElement(new htmlOutputText($sslMethod)); + $sslDelSaveGroup->addElement(new htmlSpacer('5px', null)); + // delete+download button + if ($sslFileName != null) { + $sslDownloadBtn = new htmlLink('', '../../tmp/' . $sslFileName, '../../graphics/save.png'); + $sslDownloadBtn->setTargetWindow('_blank'); + $sslDownloadBtn->setTitle(_('Download CA certificates')); + $sslDelSaveGroup->addElement($sslDownloadBtn); + $sslDeleteBtn = new htmlButton('sslCaCertDelete', 'delete.png', true); + $sslDeleteBtn->setTitle(_('Delete all CA certificates')); + $sslDelSaveGroup->addElement($sslDeleteBtn); + } + $sslDelSaveGroup->addElement(new htmlHelpLink('204')); + $row->addField($sslDelSaveGroup); + $row->addLabel(new htmlInputFileUpload('sslCaCert')); + $sslUploadBtn = new htmlButton('sslCaCertUpload', _('Upload')); + $sslUploadBtn->setIconClass('upButton'); + $sslUploadBtn->setTitle(_('Upload CA certificate in DER/PEM format.')); + $row->addField($sslUploadBtn); + if (function_exists('stream_socket_client') && function_exists('stream_context_get_params')) { + $sslImportServerUrl = !empty($_POST['serverurl']) ? $_POST['serverurl'] : 'ldaps://'; + $serverUrlUpload = new htmlInputField('serverurl', $sslImportServerUrl); + $row->addLabel($serverUrlUpload); + $sslImportBtn = new htmlButton('sslCaCertImport', _('Import from server')); + $sslImportBtn->setIconClass('downButton'); + $sslImportBtn->setTitle(_('Imports the certificate directly from your LDAP server.')); + $row->addField($sslImportBtn); + } + + $sslCerts = $cfg->getSSLCaCertificates(); + if (sizeof($sslCerts) > 0) { + $certsTitles = array(_('Common name'), _('Valid to'), _('Serial number'), _('Delete')); + $certsData = array(); + for ($i = 0; $i < sizeof($sslCerts); $i++) { + $serial = isset($sslCerts[$i]['serialNumber']) ? $sslCerts[$i]['serialNumber'] : ''; + $validTo = isset($sslCerts[$i]['validTo_time_t']) ? $sslCerts[$i]['validTo_time_t'] : ''; + $cn = isset($sslCerts[$i]['subject']['CN']) ? $sslCerts[$i]['subject']['CN'] : ''; + $delBtn = new htmlButton('deleteCert_' . $i, 'delete.png', true); + $certsData[] = array( + new htmlOutputText($cn), + new htmlOutputText($validTo), + new htmlOutputText($serial), + $delBtn + ); + } + $certsTable = new \htmlResponsiveTable($certsTitles, $certsData); + $row->add($certsTable, 12); + } + + // password policy + $row->add(new htmlSubTitle(_("Password policy")), 12); + $options20 = array(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20); + $options4 = array(0, 1, 2, 3, 4); + $row->add(new htmlResponsiveSelect('passwordMinLength', $options20, array($cfg->passwordMinLength), _('Minimum password length'), '242'), 12); + $row->addVerticalSpacer('1rem'); + $row->add(new htmlResponsiveSelect('passwordMinLower', $options20, array($cfg->passwordMinLower), _('Minimum lowercase characters'), '242'), 12); + $row->add(new htmlResponsiveSelect('passwordMinUpper', $options20, array($cfg->passwordMinUpper), _('Minimum uppercase characters'), '242'), 12); + $row->add(new htmlResponsiveSelect('passwordMinNumeric', $options20, array($cfg->passwordMinNumeric), _('Minimum numeric characters'), '242'), 12); + $row->add(new htmlResponsiveSelect('passwordMinSymbol', $options20, array($cfg->passwordMinSymbol), _('Minimum symbolic characters'), '242'), 12); + $row->add(new htmlResponsiveSelect('passwordMinClasses', $options4, array($cfg->passwordMinClasses), _('Minimum character classes'), '242'), 12); + $row->addVerticalSpacer('1rem'); + $rulesCountOptions = array(_('all') => '-1', '3' => '3', '4' => '4'); + $rulesCountSelect = new htmlResponsiveSelect('passwordRulesCount', $rulesCountOptions, array($cfg->checkedRulesCount), _('Number of rules that must match'), '246'); + $rulesCountSelect->setHasDescriptiveElements(true); + $row->add($rulesCountSelect, 12); + $passwordMustNotContainUser = ($cfg->passwordMustNotContainUser === 'true'); + $row->add(new htmlResponsiveInputCheckbox('passwordMustNotContainUser', $passwordMustNotContainUser, _('Password must not contain user name'), '247'), 12); + $passwordMustNotContain3Chars = ($cfg->passwordMustNotContain3Chars === 'true'); + $row->add(new htmlResponsiveInputCheckbox('passwordMustNotContain3Chars', $passwordMustNotContain3Chars, _('Password must not contain part of user/first/last name'), '248'), 12); + if (function_exists('curl_init')) { + $row->addVerticalSpacer('1rem'); + $row->add(new htmlResponsiveInputField(_('External password check'), 'externalPwdCheckUrl', $cfg->externalPwdCheckUrl, '249'), 12); + } + + // logging + $row->add(new htmlSubTitle(_("Logging")), 12); + $levelOptions = array(_("Debug") => LOG_DEBUG, _("Notice") => LOG_NOTICE, _("Warning") => LOG_WARNING, _("Error") => LOG_ERR); + $levelSelect = new htmlResponsiveSelect('logLevel', $levelOptions, array($cfg->logLevel), _("Log level"), '239'); + $levelSelect->setHasDescriptiveElements(true); + $row->add($levelSelect, 12); + $destinationOptions = array( + _("No logging") => "none", + _("System logging") => "syslog", + _("File") => 'file', + _("Remote") => 'remote', + ); + $destinationSelected = 'file'; + $destinationPath = $cfg->logDestination; + $destinationRemote = ''; + if ($cfg->logDestination == 'NONE') { + $destinationSelected = 'none'; + $destinationPath = ''; + } elseif ($cfg->logDestination == 'SYSLOG') { + $destinationSelected = 'syslog'; + $destinationPath = ''; + } elseif (strpos($cfg->logDestination, 'REMOTE') === 0) { + $destinationSelected = 'remote'; + $remoteParts = explode(':', $cfg->logDestination, 2); + $destinationRemote = empty($remoteParts[1]) ? '' : $remoteParts[1]; + $destinationPath = ''; + } + $logDestinationSelect = new htmlResponsiveSelect('logDestination', $destinationOptions, array($destinationSelected), _("Log destination"), '240'); + $logDestinationSelect->setTableRowsToHide(array( + 'none' => array('logFile', 'logRemote'), + 'syslog' => array('logFile', 'logRemote'), + 'remote' => array('logFile'), + 'file' => array('logRemote'), + )); + $logDestinationSelect->setTableRowsToShow(array( + 'file' => array('logFile'), + 'remote' => array('logRemote'), + )); + $logDestinationSelect->setHasDescriptiveElements(true); + $row->add($logDestinationSelect, 12); + $row->add(new htmlResponsiveInputField(_('File'), 'logFile', $destinationPath), 12); + $row->add(new htmlResponsiveInputField(_('Remote server'), 'logRemote', $destinationRemote, '251'), 12); + $errorLogOptions = array( + _('PHP system setting') => LAMCfgMain::ERROR_REPORTING_SYSTEM, + _('default') => LAMCfgMain::ERROR_REPORTING_DEFAULT, + _('all') => LAMCfgMain::ERROR_REPORTING_ALL + ); + $errorLogSelect = new htmlResponsiveSelect('errorReporting', $errorLogOptions, array($cfg->errorReporting), _('PHP error reporting'), '244'); + $errorLogSelect->setHasDescriptiveElements(true); + $row->add($errorLogSelect, 12); + + // additional options + if (isLAMProVersion()) { + $row->add(new htmlSubTitle(_('Additional options')), 12); + $mailEOLOptions = array( + _('Default (\r\n)') => 'default', + _('Non-standard (\n)') => 'unix' + ); + $mailEOLSelect = new htmlResponsiveSelect('mailEOL', $mailEOLOptions, array($cfg->mailEOL), _('Email format'), '243'); + $mailEOLSelect->setHasDescriptiveElements(true); + $row->add($mailEOLSelect, 12); + } + $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'); + $pwd1->setIsPassword(true, false, true); + $row->add($pwd1, 12); + $pwd2 = new htmlResponsiveInputField(_("Reenter password"), 'masterpassword2', ''); + $pwd2->setIsPassword(true, false, true); + $pwd2->setSameValueFieldID('masterpassword'); + $row->add($pwd2, 12); + $row->addVerticalSpacer('3rem'); + + // buttons + if ($cfg->isWritable()) { + $buttonTable = new htmlTable(); + $buttonTable->addElement(new htmlButton('submit', _("Ok"))); + $buttonTable->addElement(new htmlSpacer('1rem', null)); + $buttonTable->addElement(new htmlButton('cancel', _("Cancel"))); + $row->add($buttonTable, 12); + $row->add(new htmlHiddenInput('submitFormData', '1'), 12); + } + + $box = new htmlDiv(null, $row); + $box->setCSSClasses(array('ui-corner-all', 'roundedShadowBox')); + parseHtml(null, $box, array(), false, $tabindex, 'user'); + /** + * Formats an LDAP time string (e.g. from createTimestamp). + * + * @param String $time LDAP time value + * @return String formated time + */ + function formatSSLTimestamp($time) { + if (!empty($time)) { + $timeZone = 'UTC'; + $sysTimeZone = @date_default_timezone_get(); + if (!empty($sysTimeZone)) { + $timeZone = $sysTimeZone; + } + $date = new DateTime('@' . $time, new DateTimeZone($timeZone)); + return $date->format('d.m.Y'); + } + return ''; + } -?> -
-


+ ?> - + +


+ + diff --git a/lam/templates/lib/500_lam.js b/lam/templates/lib/500_lam.js index 871ddb84..26d57702 100644 --- a/lam/templates/lib/500_lam.js +++ b/lam/templates/lib/500_lam.js @@ -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(); }); /** diff --git a/lam/templates/misc/ajax.php b/lam/templates/misc/ajax.php index 61d1cb62..b759b844 100644 --- a/lam/templates/misc/ajax.php +++ b/lam/templates/misc/ajax.php @@ -1,5 +1,7 @@ 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(); + } + } diff --git a/lam/tests/lib/webauthnDbTest.php b/lam/tests/lib/webauthnDbTest.php index a4ffdbef..d6478b19 100644 --- a/lam/tests/lib/webauthnDbTest.php +++ b/lam/tests/lib/webauthnDbTest.php @@ -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'))); + } + }