diff --git a/composer.json b/composer.json index 45ec44a..b7316df 100644 --- a/composer.json +++ b/composer.json @@ -43,7 +43,10 @@ "oxid-esales/oxideshop-ce": "6.8.0 - 6.12", "spomky-labs/otphp": "^10.0 || ^11.0", "bacon/bacon-qr-code": "^2.0", - "laminas/laminas-math": "^3.2" + "laminas/laminas-math": "^3.2", + "web-auth/webauthn-lib": "^3.3", + "nyholm/psr7": "^1.5.1", + "nyholm/psr7-server": "^1.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^2.19", diff --git a/src/Application/Model/Webauthn/PublicKeyCredentials.php b/src/Application/Model/Webauthn/PublicKeyCredentials.php new file mode 100644 index 0000000..837dfbc --- /dev/null +++ b/src/Application/Model/Webauthn/PublicKeyCredentials.php @@ -0,0 +1,84 @@ +getContainer()->get(QueryBuilderFactoryInterface::class)->create(); + $qb->select('credential') + ->from($this->getViewName()) + ->where( + $qb->expr()->and( + $qb->expr()->eq( + 'credid_hex', + $qb->createNamedParameter(bin2hex($publicKeyCredentialId)) + ), + $qb->expr()->eq( + 'shopid', + $qb->createNamedParameter(Registry::getConfig()->getShopId()) + ) + ) + ); + $credential = $qb->execute()->fetchOne(); + + return strlen($credential) ? hex2bin(unserialize($credential)) : null; + } + + public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array + { + /** @var QueryBuilder $qb */ + $qb = ContainerFactory::getInstance()->getContainer()->get(QueryBuilderFactoryInterface::class)->create(); + $qb->select('credential') + ->from($this->getViewName()) + ->where( + $qb->expr()->and( + $qb->expr()->eq( + 'oxuserid', + $qb->createNamedParameter($publicKeyCredentialUserEntity->getId()) + ), + $qb->expr()->eq( + 'oxshopid', + $qb->createNamedParameter(Registry::getConfig()->getShopId()) + ) + ) + ); + + // generate decoded credentials list + return array_map(function (array $fields) { + return unserialize(hex2bin($fields['credential'])); + }, $qb->execute()->fetchAllAssociative()); + } + + /** + * @param PublicKeyCredentialSource $publicKeyCredentialSource + * @return void + * @throws \Exception + */ + public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void + { + $this->assign([ + 'oxshopid' => Registry::getConfig()->getShopId(), + 'oxuserid' => $publicKeyCredentialSource->getUserHandle(), + 'credid_bin' => $publicKeyCredentialSource->getPublicKeyCredentialId(), + 'credid_hex' => bin2hex($publicKeyCredentialSource->getPublicKeyCredentialId()), + 'pubkey_bin' => $publicKeyCredentialSource->getCredentialPublicKey(), + 'pubkey_hex' => bin2hex($publicKeyCredentialSource->getCredentialPublicKey()), + 'credential' => bin2hex(serialize($publicKeyCredentialSource)), + ]); + $this->save(); + } +} \ No newline at end of file diff --git a/src/Application/Model/Webauthn/Webauthn.php b/src/Application/Model/Webauthn/Webauthn.php new file mode 100644 index 0000000..23d2278 --- /dev/null +++ b/src/Application/Model/Webauthn/Webauthn.php @@ -0,0 +1,96 @@ +load('oxdefaultadmin'); + $userEntity = $user->d3GetWebauthnUserEntity(); + + Registry::getSession()->setVariable(self::SESSION_USERENTITY, $userEntity); + + $credentialSourceRepository = new PublicKeyCredentials(); + $credentialSources = $credentialSourceRepository->findAllForUserEntity($userEntity); + $excludeCredentials = array_map(function (PublicKeyCredentialSource $credential) { + return $credential->getPublicKeyCredentialDescriptor(); + }, $credentialSources); + + $server = $this->getServer(); + $publicKeyCredentialCreationOptions = $server->generatePublicKeyCredentialCreationOptions( + $userEntity, + PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE, + $excludeCredentials + ); + + Registry::getSession()->setVariable(self::SESSION_CREATIONS_OPTIONS, $publicKeyCredentialCreationOptions); + + return json_encode($publicKeyCredentialCreationOptions); + } + + /** + * @return Server + */ + public function getServer() + { + $rpEntity = new PublicKeyCredentialRpEntity( + Registry::getConfig()->getActiveShop()->getFieldData('oxname'), + preg_replace('/(^www\.)(.*)/mi', '$2', $_SERVER['HTTP_HOST']) + ); + + return new Server($rpEntity, new PublicKeyCredentials()); + } + + public function saveAuthn(string $credential) + { + try { + $psr17Factory = new Psr17Factory(); + $creator = new ServerRequestCreator( + $psr17Factory, + $psr17Factory, + $psr17Factory, + $psr17Factory + ); + $serverRequest = $creator->fromGlobals(); + + $publicKeyCredentialSource = $this->getServer()->loadAndCheckAttestationResponse( + html_entity_decode($credential), + Registry::getSession()->getVariable(self::SESSION_CREATIONS_OPTIONS), + $serverRequest + ); + + dumpvar($publicKeyCredentialSource); + dumpvar(serialize($publicKeyCredentialSource)); + dumpvar(unserialize(serialize($publicKeyCredentialSource))); + echo "
"; + dumpvar(bin2hex(serialize($publicKeyCredentialSource))); + dumpvar(unserialize(hex2bin(bin2hex(serialize($publicKeyCredentialSource))))); + + $pkCredential = oxNew(PublicKeyCredentials::class); + $pkCredential->saveCredentialSource($publicKeyCredentialSource); + } catch (\Exception $e) { + dumpvar($e->getMessage()); + dumpvar($e); + + die(); + } + } +} \ No newline at end of file diff --git a/src/Application/views/blocks/page/shop/start_welcome_text.tpl b/src/Application/views/blocks/page/shop/start_welcome_text.tpl new file mode 100644 index 0000000..c473b35 --- /dev/null +++ b/src/Application/views/blocks/page/shop/start_welcome_text.tpl @@ -0,0 +1,38 @@ +[{* from https://github.com/jcjones/webauthn.bin.coffee *}] + +[{oxscript include=$oViewConf->getModuleUrl('d3totp', 'out/src/js/index.js')}] + +[{capture name="d3script"}] + var creationOptions = [{$creationOptions}]; + createCredentials(creationOptions); + [{*** + // Import the tool(s) ou need + import {useRegistration} from '@web-auth/webauthn-helper'; + + // We want to register new authenticators + const register = useRegistration({ + actionUrl: '/api/register', + optionsUrl: '/api/register/options' + }); + + register({ + username: 'FOO4', + displayName: 'baR' + }) + .then((response)=> console.log('Registration success')) + .catch((error)=> console.log('Registration failure')) + ; + ***}] +[{/capture}] +[{oxscript add=$smarty.capture.d3script}] +--A-- +
+ [{$oViewConf->getHiddenSid()}] + [{$oViewConf->getNavFormParams()}] + + + +
+--B-- + +[{$smarty.block.parent}] \ No newline at end of file diff --git a/src/Modules/Application/Controller/d3_totp_StartController.php b/src/Modules/Application/Controller/d3_totp_StartController.php new file mode 100644 index 0000000..85df5cf --- /dev/null +++ b/src/Modules/Application/Controller/d3_totp_StartController.php @@ -0,0 +1,23 @@ +addTplParam('creationOptions', $webAuthn->getCreationOptions()); + + return parent::render(); + } + + public function saveAuthn() + { + $webAuthn = oxNew(Webauthn::class); + $webAuthn->saveAuthn(Registry::getRequest()->getRequestEscapedParameter('credential')); + } +} diff --git a/src/Modules/Application/Model/d3_totp_user.php b/src/Modules/Application/Model/d3_totp_user.php index 3f4fed5..bdf2468 100644 --- a/src/Modules/Application/Model/d3_totp_user.php +++ b/src/Modules/Application/Model/d3_totp_user.php @@ -16,8 +16,10 @@ declare(strict_types=1); namespace D3\Totp\Modules\Application\Model; use D3\Totp\Application\Model\d3totp; +use OxidEsales\Eshop\Core\Exception\StandardException; use OxidEsales\Eshop\Core\Registry; use OxidEsales\Eshop\Core\Session; +use Webauthn\PublicKeyCredentialUserEntity; class d3_totp_user extends d3_totp_user_parent { @@ -45,4 +47,20 @@ class d3_totp_user extends d3_totp_user_parent { return Registry::getSession(); } -} + + /** + * @return PublicKeyCredentialUserEntity + */ + public function d3GetWebauthnUserEntity(): PublicKeyCredentialUserEntity + { + if ($this->isLoaded()) { + return oxNew(PublicKeyCredentialUserEntity::class, + $this->getFieldData('oxusername'), + $this->getId(), + $this->getFieldData('oxfname') . ' ' . $this->getFieldData('oxlname') + ); + } + + throw oxNew(StandardException::class, 'can not create webauthn user entity from not loaded user'); + } +} \ No newline at end of file diff --git a/src/metadata.php b/src/metadata.php index 4dcab15..2ef7be6 100644 --- a/src/metadata.php +++ b/src/metadata.php @@ -21,6 +21,7 @@ use D3\Totp\Modules\Application\Component\d3_totp_UserComponent; use D3\Totp\Modules\Application\Controller\Admin\d3_totp_LoginController; use D3\Totp\Modules\Application\Controller\d3_totp_OrderController; use D3\Totp\Modules\Application\Controller\d3_totp_PaymentController; +use D3\Totp\Modules\Application\Controller\d3_totp_StartController; use D3\Totp\Modules\Application\Controller\d3_totp_UserController; use D3\Totp\Modules\Application\Model\d3_totp_user; use D3\Totp\Modules\Core\d3_totp_utils; @@ -29,6 +30,7 @@ use OxidEsales\Eshop\Application\Component\UserComponent; use OxidEsales\Eshop\Application\Controller\Admin\LoginController; use OxidEsales\Eshop\Application\Controller\OrderController; use OxidEsales\Eshop\Application\Controller\PaymentController; +use OxidEsales\Eshop\Application\Controller\StartController; use OxidEsales\Eshop\Application\Controller\UserController; use OxidEsales\Eshop\Core\Utils; use OxidEsales\Eshop\Application\Model as OxidModel; @@ -66,6 +68,7 @@ $aModule = [ LoginController::class => d3_totp_LoginController::class, Utils::class => d3_totp_utils::class, UserComponent::class => d3_totp_UserComponent::class, + StartController::class => d3_totp_StartController::class ], 'controllers' => [ 'd3user_totp' => d3user_totp::class, @@ -101,5 +104,10 @@ $aModule = [ 'block' => 'account_menu', 'file' => 'Application/views/blocks/page/account/inc/account_menu.tpl', ], - ], + [ + 'template' => 'page/shop/start.tpl', + 'block' => 'start_welcome_text', + 'file' => 'Application/views/blocks/page/shop/start_welcome_text.tpl', + ], + ] ]; diff --git a/src/out/src/js/index.js b/src/out/src/js/index.js new file mode 100644 index 0000000..d62a4f8 --- /dev/null +++ b/src/out/src/js/index.js @@ -0,0 +1,152 @@ +if (!window.PublicKeyCredential) { + console.error('no window pubkeycred available'); +} + +const base64UrlDecode = (input) => { + "use strict"; + input = input + .replace(/-/g, '+') + .replace(/_/g, '/'); + + const pad = input.length % 4; + if (pad) { + if (pad === 1) { + throw new Error('InvalidLengthError: Input base64url string is the wrong length to determine padding'); + } + input += new Array(5-pad).join('='); + } + + return window.atob(input); +}; + +const preparePublicKeyOptions = (publicKey) => { + "use strict"; + //Convert challenge from Base64Url string to Uint8Array + publicKey.challenge = Uint8Array.from( + base64UrlDecode(publicKey.challenge), + c => c.charCodeAt(0) + ); + + //Convert the user ID from Base64 string to Uint8Array + if (publicKey.user !== undefined) { + publicKey.user = { + ...publicKey.user, + id: Uint8Array.from( + window.atob(publicKey.user.id), + c => c.charCodeAt(0) + ), + }; + } + + //If excludeCredentials is defined, we convert all IDs to Uint8Array + if (publicKey.excludeCredentials !== undefined) { + publicKey.excludeCredentials = publicKey.excludeCredentials.map( + data => { + return { + ...data, + id: Uint8Array.from( + base64UrlDecode(data.id), + c => c.charCodeAt(0) + ), + }; + } + ); + } + + if (publicKey.allowCredentials !== undefined) { + publicKey.allowCredentials = publicKey.allowCredentials.map( + data => { + return { + ...data, + id: Uint8Array.from( + base64UrlDecode(data.id), + c => c.charCodeAt(0) + ), + }; + } + ); + } + + return publicKey; +}; + +function base64ArrayBuffer(arrayBuffer) { + "use strict"; + var base64 = '' + var encodings = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + + var bytes = new Uint8Array(arrayBuffer) + var byteLength = bytes.byteLength + var byteRemainder = byteLength % 3 + var mainLength = byteLength - byteRemainder + + var a, b, c, d + var chunk + + // Main loop deals with bytes in chunks of 3 + for (var i = 0; i < mainLength; i = i + 3) { + // Combine the three bytes into a single integer + chunk = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2] + + // Use bitmasks to extract 6-bit segments from the triplet + a = (chunk & 16515072) >> 18 // 16515072 = (2^6 - 1) << 18 + b = (chunk & 258048) >> 12 // 258048 = (2^6 - 1) << 12 + c = (chunk & 4032) >> 6 // 4032 = (2^6 - 1) << 6 + d = chunk & 63 // 63 = 2^6 - 1 + + // Convert the raw binary segments to the appropriate ASCII encoding + base64 += encodings[a] + encodings[b] + encodings[c] + encodings[d] + } + + // Deal with the remaining bytes and padding + if (byteRemainder === 1) { + chunk = bytes[mainLength] + + a = (chunk & 252) >> 2 // 252 = (2^6 - 1) << 2 + + // Set the 4 least significant bits to zero + b = (chunk & 3) << 4 // 3 = 2^2 - 1 + + base64 += encodings[a] + encodings[b] + '==' + } else if (byteRemainder === 2) { + chunk = (bytes[mainLength] << 8) | bytes[mainLength + 1] + + a = (chunk & 64512) >> 10 // 64512 = (2^6 - 1) << 10 + b = (chunk & 1008) >> 4 // 1008 = (2^6 - 1) << 4 + + // Set the 2 least significant bits to zero + c = (chunk & 15) << 2 // 15 = 2^4 - 1 + + base64 += encodings[a] + encodings[b] + encodings[c] + '=' + } + + return base64 +} + +const createCredentials = (publicKey) => { + "use strict"; + + preparePublicKeyOptions(publicKey); + + navigator.credentials.create({publicKey: publicKey}) + .then(function (newCredentialInfo) { + // Send new credential info to server for verification and registration. + var cred = { + id: newCredentialInfo.id, + rawId: base64ArrayBuffer(newCredentialInfo.rawId), + response: { + clientDataJSON: base64ArrayBuffer(newCredentialInfo.response.clientDataJSON), + attestationObject: base64ArrayBuffer(newCredentialInfo.response.attestationObject) + }, + type: newCredentialInfo.type + }; + + document.getElementById('webauthn').credential.value = JSON.stringify(cred); + document.getElementById('webauthn').submit(); + }).catch(function (err) { + console.log('--2--'); + console.log('WebAuthn create: ' + err); + // No acceptable authenticator or user refused consent. Handle appropriately. + }); +} +