diff --git a/lam/lib/2factor.inc b/lam/lib/2factor.inc
index d4499583..e3d26062 100644
--- a/lam/lib/2factor.inc
+++ b/lam/lib/2factor.inc
@@ -573,13 +573,26 @@ class WebauthnProvider extends BaseProvider {
logNewMessage(LOG_DEBUG, 'WebauthnProvider: Checking 2nd factor for ' . $user);
include_once __DIR__ . '/webauthn.inc';
$webauthnManager = new WebauthnManager();
- if ($this->config->twoFactorAuthenticationOptional && !$webauthnManager->isRegistered($user) && ($_POST['sig_response'] === 'skip')) {
- return true;
+ if (!empty($_SESSION['ldap'])) {
+ $userDn = $_SESSION['ldap']->getUserName();
}
- $response = base64_decode($_POST['sig_response']);
- $registrationObject = PublicKeyCredentialCreationOptions::createFromString($_SESSION['webauthn_registration']);
- if ($webauthnManager->storeNewRegistration($registrationObject, $response)) {
- return true;
+ else {
+ $userDn = $_SESSION['selfService_clientDN'];
+ }
+ $hasTokens = $webauthnManager->isRegistered($userDn);
+ if (!$hasTokens) {
+ if ($this->config->twoFactorAuthenticationOptional && !$webauthnManager->isRegistered($user) && ($_POST['sig_response'] === 'skip')) {
+ logNewMessage(LOG_DEBUG, 'Skipped 2FA for ' . $user . ' as no devices are registered and 2FA is optional.');
+ return true;
+ }
+ $response = base64_decode($_POST['sig_response']);
+ $registrationObject = PublicKeyCredentialCreationOptions::createFromString($_SESSION['webauthn_registration']);
+ return $webauthnManager->storeNewRegistration($registrationObject, $response);
+ }
+ else {
+ logNewMessage(LOG_DEBUG, 'Checking webauthn response of ' . $userDn);
+ $response = base64_decode($_POST['sig_response']);
+ return $webauthnManager->isValidAuthentication($response, $userDn);
}
logNewMessage(LOG_ERR, 'Webauthn authentication failed');
return false;
diff --git a/lam/lib/webauthn.inc b/lam/lib/webauthn.inc
index ed3b63d9..2fa54a44 100644
--- a/lam/lib/webauthn.inc
+++ b/lam/lib/webauthn.inc
@@ -19,6 +19,7 @@ use Nyholm\Psr7\Factory\Psr17Factory;
use PDO;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
use Symfony\Component\HttpFoundation\Request;
+use Throwable;
use Webauthn\AttestationStatement\AndroidKeyAttestationStatementSupport;
use Webauthn\AttestationStatement\AttestationObjectLoader;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
@@ -27,11 +28,14 @@ use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
use Webauthn\AttestationStatement\PackedAttestationStatementSupport;
use Webauthn\AttestationStatement\TPMAttestationStatementSupport;
use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
+use Webauthn\AuthenticatorAssertionResponse;
+use Webauthn\AuthenticatorAssertionResponseValidator;
use Webauthn\AuthenticatorAttestationResponse;
use Webauthn\AuthenticatorAttestationResponseValidator;
use \Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialDescriptor;
use Webauthn\PublicKeyCredentialLoader;
+use Webauthn\PublicKeyCredentialRequestOptions;
use \Webauthn\PublicKeyCredentialRpEntity;
use \Webauthn\PublicKeyCredentialParameters;
use Webauthn\PublicKeyCredentialSource;
@@ -101,10 +105,10 @@ class WebauthnManager {
public function getRegistrationObject($dn, $isSelfService) {
$rpEntity = $this->createRpEntry($isSelfService);
$userEntity = $this->getUserEntity($dn);
- $challenge = generateRandomPassword(32);
+ $challenge = $this->createChallenge();
$credentialParameters = $this->getCredentialParameters();
$excludedKeys = $this->getExcludedKeys($userEntity);
- $timeout = 20000;
+ $timeout = $this->getTimeout();
$registrationObject = new PublicKeyCredentialCreationOptions(
$rpEntity,
$userEntity,
@@ -240,6 +244,17 @@ class WebauthnManager {
$manager->add(new FidoU2FAttestationStatementSupport());
$manager->add(new AndroidKeyAttestationStatementSupport($decoder));
$manager->add(new TPMAttestationStatementSupport());
+ $coseManager = $this->getAlgorithmManager();
+ $manager->add(new PackedAttestationStatementSupport($decoder, $coseManager));
+ return $manager;
+ }
+
+ /**
+ * Returns the COSE algorithm manager.
+ *
+ * @return Manager algorithm manager
+ */
+ private function getAlgorithmManager() {
$coseManager = new Manager();
$coseManager->add(new ES256());
$coseManager->add(new ES384());
@@ -249,8 +264,7 @@ class WebauthnManager {
$coseManager->add(new RS256());
$coseManager->add(new RS384);
$coseManager->add(new RS512());
- $manager->add(new PackedAttestationStatementSupport($decoder, $coseManager));
- return $manager;
+ return $coseManager;
}
/**
@@ -294,6 +308,104 @@ class WebauthnManager {
return new PublicKeyCredentialSourceRepositorySQLite();
}
+ /**
+ * Returns the timeout for user operations.
+ *
+ * @return int timeout in ms
+ */
+ private function getTimeout() {
+ return 120000;
+ }
+
+ /**
+ * Creates a new challenge.
+ *
+ * @return String challenge
+ */
+ private function createChallenge() {
+ return generateRandomPassword(32);
+ }
+
+ /**
+ * Returns the authentication object for a registered user.
+ *
+ * @param $userDN user DN
+ * @param bool $isSelfService self service
+ * @return PublicKeyCredentialRequestOptions authentication object
+ */
+ public function getAuthenticationObject($userDN, bool $isSelfService) {
+ $timeout = $this->getTimeout();
+ $challenge = $this->createChallenge();
+ $database = $this->getDatabase();
+ $userEntity = $this->getUserEntity($userDN);
+ $publicKeyCredentialSources = $database->findAllForUserEntity($userEntity);
+ $publicKeyDescriptors = array();
+ foreach ($publicKeyCredentialSources as $publicKeyCredentialSource) {
+ $publicKeyDescriptors[] = $publicKeyCredentialSource->getPublicKeyCredentialDescriptor();
+ }
+ $userVerification = PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_DISCOURAGED;
+ $extensions = new AuthenticationExtensionsClientInputs();
+ $relyingParty = $this->createRpEntry($isSelfService);
+ return new PublicKeyCredentialRequestOptions(
+ $challenge,
+ $timeout,
+ $relyingParty->getId(),
+ $publicKeyDescriptors,
+ $userVerification,
+ $extensions
+ );
+ }
+
+ /**
+ * Checks if the provided authentication is valid.
+ *
+ * @param string $response authentication response
+ * @param string $userDn user DN
+ * @return bool true if all is ok
+ */
+ public function isValidAuthentication(string $response, string $userDn) {
+ $database = $this->getDatabase();
+ $decoder = $this->getCborDecoder();
+ $tokenBindingHandler = new IgnoreTokenBindingHandler();
+ $extensionOutputCheckerHandler = $this->getExtensionOutputChecker();
+ $algorithmManager = $this->getAlgorithmManager();
+ $responseValidator = new AuthenticatorAssertionResponseValidator(
+ $database,
+ $decoder,
+ $tokenBindingHandler,
+ $extensionOutputCheckerHandler,
+ $algorithmManager
+ );
+ $attestationSupportManager = $this->getAttestationSupportManager($decoder);
+ $attestationObjectLoader = $this->getAttestationObjectLoader($attestationSupportManager, $decoder);
+ $publicKeyCredentialLoader = $this->getPublicKeyCredentialLoader($attestationObjectLoader, $decoder);
+ $publicKeyCredential = $publicKeyCredentialLoader->load($response);
+ $authenticatorAssertionResponse = $publicKeyCredential->getResponse();
+ if (!$authenticatorAssertionResponse instanceof AuthenticatorAssertionResponse) {
+ logNewMessage(LOG_ERR, 'Invalid authenticator assertion response');
+ return false;
+ }
+ try {
+ $symfonyRequest = Request::createFromGlobals();
+ $psr17Factory = new Psr17Factory();
+ $psrFactory = new PsrHttpFactory($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory);
+ $psr7Request = $psrFactory->createRequest($symfonyRequest);
+ $publicKeyCredentialRequestOptions = PublicKeyCredentialRequestOptions::createFromString($_SESSION['webauthn_authentication']);
+ $responseValidator->check(
+ $publicKeyCredential->getRawId(),
+ $publicKeyCredential->getResponse(),
+ $publicKeyCredentialRequestOptions,
+ $psr7Request,
+ $userDn
+ );
+ return true;
+ }
+ catch (Throwable $e) {
+ logNewMessage(LOG_ERR, 'Error validating webauthn authentication: ' . $e->getMessage());
+ }
+ return false;
+ }
+
}
/**
@@ -361,16 +473,39 @@ class PublicKeyCredentialSourceRepositorySQLite implements PublicKeyCredentialSo
$json = json_encode($publicKeyCredentialSource);
$credentialId = base64_encode($publicKeyCredentialSource->getPublicKeyCredentialId());
$userId = $publicKeyCredentialSource->getUserHandle();
- $registrationTime = time();
+ $currentTime = time();
$pdo = $this->getPDO();
- $statement = $pdo->prepare('insert into ' . self::TABLE_NAME . ' (userId, credentialId, credentialSource, registrationTime) VALUES (?, ?, ?, ?)');
+ $statement = $pdo->prepare('select * from ' . self::TABLE_NAME . ' where userId = :userId and credentialId = :credentialId');
$statement->execute(array(
- $userId,
- $credentialId,
- $json,
- $registrationTime
+ ':userId' => $userId,
+ ':credentialId' => $credentialId
));
- logNewMessage(LOG_DEBUG, 'Stored new credential for ' . $userId);
+ $results = $statement->fetchAll();
+ if (empty($results)) {
+ $statement = $pdo->prepare('insert into ' . self::TABLE_NAME . ' (userId, credentialId, credentialSource, registrationTime, lastUseTime) VALUES (?, ?, ?, ?, ?)');
+ $statement->execute(array(
+ $userId,
+ $credentialId,
+ $json,
+ $currentTime,
+ $currentTime
+ ));
+ logNewMessage(LOG_DEBUG, 'Stored new credential for ' . $userId);
+ }
+ else {
+ $statement = $pdo->prepare(
+ 'update ' . self::TABLE_NAME .
+ ' set credentialSource = :credentialSource, lastUseTime = :lastUseTime' .
+ ' WHERE userId = :userId AND credentialId = :credentialId'
+ );
+ $statement->execute(array(
+ ':credentialSource' => $json,
+ ':lastUseTime' => $currentTime,
+ ':userId' => $userId,
+ ':credentialId' => $credentialId
+ ));
+ logNewMessage(LOG_DEBUG, 'Stored updated credential for ' . $userId);
+ }
}
/**
@@ -432,6 +567,7 @@ class PublicKeyCredentialSourceRepositorySQLite implements PublicKeyCredentialSo
. 'credentialId TEXT NOT NULL,'
. 'credentialSource TEXT NOT NULL,'
. 'registrationTime VARCHAR(11) NOT NULL,'
+ . 'lastUseTime VARCHAR(11) NOT NULL,'
. 'PRIMARY KEY(userId,credentialId)'
. ');';
$pdo->exec($sql);
diff --git a/lam/templates/lib/500_lam.js b/lam/templates/lib/500_lam.js
index 67a27338..871ddb84 100644
--- a/lam/templates/lib/500_lam.js
+++ b/lam/templates/lib/500_lam.js
@@ -1409,6 +1409,9 @@ window.lam.webauthn.run = function(prefix) {
if (jsonData.action === 'register') {
window.lam.webauthn.register(jsonData.registration);
}
+ else if (jsonData.action === 'authenticate') {
+ window.lam.webauthn.authenticate(jsonData.authentication);
+ }
})
.fail(function() {
console.log('Webauthn failed');
@@ -1425,7 +1428,7 @@ window.lam.webauthn.register = function(publicKey) {
publicKey.user.id = Uint8Array.from(window.atob(publicKey.user.id), c=>c.charCodeAt(0));
publicKey.rp.icon = window.location.href.substring(0, window.location.href.lastIndexOf("/")) + publicKey.rp.icon;
if (publicKey.excludeCredentials) {
- for (var i = 0; i < publicKey.excludeCredentials.length; i++) {
+ for (let i = 0; i < publicKey.excludeCredentials.length; i++) {
let idOrig = publicKey.excludeCredentials[i]['id'];
idOrig = idOrig.replace(/-/g, "+").replace(/_/g, "/");
let idOrigDecoded = atob(idOrig);
@@ -1449,6 +1452,52 @@ window.lam.webauthn.register = function(publicKey) {
form.append('');
form.submit();
}, function (error) {
+ console.log(error.message);
+ 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();
+ });
+ });
+}
+
+/**
+ * Performs a webauthn authentication.
+ *
+ * @param publicKey authentication object
+ */
+window.lam.webauthn.authenticate = function(publicKey) {
+ publicKey.challenge = Uint8Array.from(window.atob(publicKey.challenge), c => c.charCodeAt(0));
+ for (let i = 0; i < publicKey.allowCredentials.length; i++) {
+ let idOrig = publicKey.allowCredentials[i]['id'];
+ idOrig = idOrig.replace(/-/g, "+").replace(/_/g, "/");
+ let idOrigDecoded = atob(idOrig);
+ let idArray = Uint8Array.from(idOrigDecoded, c => c.charCodeAt(0))
+ publicKey.allowCredentials[i]['id'] = idArray;
+ }
+ navigator.credentials.get({publicKey})
+ .then(data => {
+ let publicKeyCredential = {
+ id: data.id,
+ type: data.type,
+ rawId: window.lam.webauthn.arrayToBase64String(new Uint8Array(data.rawId)),
+ response: {
+ authenticatorData: window.lam.webauthn.arrayToBase64String(new Uint8Array(data.response.authenticatorData)),
+ clientDataJSON: window.lam.webauthn.arrayToBase64String(new Uint8Array(data.response.clientDataJSON)),
+ signature: window.lam.webauthn.arrayToBase64String(new Uint8Array(data.response.signature)),
+ userHandle: data.response.userHandle ? window.lam.webauthn.arrayToBase64String(new Uint8Array(data.response.userHandle)) : null
+ }
+ };
+ let form = jQuery("#2faform");
+ let response = btoa(JSON.stringify(publicKeyCredential));
+ form.append('');
+ form.submit();
+ }, error => {
console.log(error.message);
let errorDiv = jQuery('#generic-webauthn-error');
let buttonLabel = errorDiv.data('button');
diff --git a/lam/templates/misc/ajax.php b/lam/templates/misc/ajax.php
index 572871a8..61d1cb62 100644
--- a/lam/templates/misc/ajax.php
+++ b/lam/templates/misc/ajax.php
@@ -204,8 +204,19 @@ class Ajax {
),
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
- die();
}
+ else {
+ $authenticationObject = $webauthnManager->getAuthenticationObject($userDN, $isSelfService);
+ $_SESSION['webauthn_authentication'] = json_encode($authenticationObject);
+ echo json_encode(
+ array(
+ 'action' => 'authenticate',
+ 'authentication' => $authenticationObject
+ ),
+ JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
+ );
+ }
+ die();
}
/**