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(); } /**