commit 4872d55ed65863e03102eea1046504e630f80ae2 Author: Deon George Date: Thu Apr 25 15:05:51 2024 +1000 Initial implementation diff --git a/README.md b/README.md new file mode 100644 index 0000000..5efcd9e --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# Implementing passkey + +## Add passkey to user update form + +```javascript + + + + +``` + +## Enable passkey login + +```javascript + + + + +``` \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..3dcd5ec --- /dev/null +++ b/composer.json @@ -0,0 +1,33 @@ +{ + "name": "leenooks/passkey", + "description": "Leenooks Laravel Passkey Implementation", + "keywords": [ + "laravel", + "dege", + "passkey" + ], + "license": "MIT", + "authors": [ + { + "name": "Deon George", + "email": "deon@dege.au" + } + ], + "require": { + "lbuchs/webauthn": "^2.1" + }, + "require-dev": {}, + "autoload": { + "psr-4": { + "Leenooks\\Passkey\\": "src" + } + }, + "extra": { + "laravel": { + "providers": [ + "Leenooks\\Passkey\\PasskeyServiceProvider" + ] + } + }, + "minimum-stability": "dev" +} diff --git a/src/Controllers/PasskeyController.php b/src/Controllers/PasskeyController.php new file mode 100644 index 0000000..4f8f596 --- /dev/null +++ b/src/Controllers/PasskeyController.php @@ -0,0 +1,232 @@ +init(); + $ids = []; + + $getArgs = $this->webauthn->getGetArgs( + $ids, + $this->timeout, + $this->typeUsb, + $this->typeNfc, + $this->typeBle, + $this->typeHyb, + $this->typeInt, + $this->userVerification + ); + + session()->put('challenge',$this->webauthn->getChallenge()); + + return $getArgs; + } + + /** + * This function takes a browsers generated passkey, checks that its valid and registers it. + * + * @param Request $request + * @return array + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + * @throws \lbuchs\WebAuthn\WebAuthnException + */ + public function check(Request $request): array + { + Log::debug(sprintf('%s:- Check',self::LOGKEY),['post'=>$request->post()]); + + $this->init(); + + $challenge = session()->pull('challenge'); + Log::debug(sprintf('%s:- Challenge Retrieved [%s]',self::LOGKEY,$challenge)); + + // processCreate returns data to be stored for future logins. + $data = $this->webauthn->processCreate( + base64_decode($request->clientDataJSON), + base64_decode($request->attestationObject), + $challenge, + $this->userVerification === 'required', + TRUE, + FALSE, + ); + Log::debug(sprintf('%s:- process Create Data',self::LOGKEY),['data'=>serialize($data)]); + + $uo = Auth::user(); + $uo->passkey = [ + 'id' => base64_encode($data->credentialId), + 'pk' => $data->credentialPublicKey, + ]; + $uo->save(); + + if ($data->rootValid === FALSE) + Log::alert(sprintf('%s:- registration ok, but certificate does not match any of the selected root ca.',self::LOGKEY)); + + Log::debug(sprintf('%s:= Passkey [%s] created for [%d]',self::LOGKEY,$uo->passkey['id'],$uo->id)); + + return [ + 'msg' => 'Registration Successful', + 'success' => TRUE, + ]; + } + + private function init() + { + Log::debug(sprintf('%s:- Init for [%d]',self::LOGKEY,Auth::id())); + + $this->userId = sprintf('%08x',Auth::id()); + $this->userName = Auth::id() ? Auth::user()->email : ''; + $this->userDisplayName = Auth::id() ? preg_replace('/[^0-9a-z öüäéèàÖÜÄÉÈÀÂÊÎÔÛâêîôû]/i','',Auth::user()->name) : ''; + + // cross-platform: true, if type internal is not allowed + // false, if only internal is allowed + // null, if internal and cross-platform is allowed + if (($this->typeUsb || $this->typeNfc || $this->typeBle || $this->typeHyb) && (! $this->typeInt)) { + $this->crossPlatformAttachment = TRUE; + + } elseif ((! $this->typeUsb) && (! $this->typeNfc) && (! $this->typeBle) && (! $this->typeHyb) && $this->typeInt) { + $this->crossPlatformAttachment = FALSE; + } + + $this->webauthn = new WebAuthn('WebAuthn Library',request()->getHttpHost(),$this->formats); + } + + // processGet + public function process(Request $request): array + { + Log::debug(sprintf('%s:- Process',self::LOGKEY),['post'=>$request->post()]); + + $this->init(); + + $userHandle = base64_decode($request->userHandle); + $challenge = session()->pull('challenge'); + + Log::debug(sprintf('%s:- ID [%s], Handle [%s] (%s)',self::LOGKEY,$request->id,bin2hex($userHandle),$request->userHandle)); + + $uo = User::select(['id','email','passkey']) + ->active() + ->where('passkey->id',$request->id) + ->single(); + + if (! $uo) + return [ + 'msg' => 'Passkey Not Found', + 'success' => FALSE, + ]; + + // if we have resident key, we have to verify that the userHandle is the provided userId at registration + if ($this->requireResidentKey && ($userHandle !== hex2bin(sprintf('%08s',$uo->id)))) + throw new \Exception(sprintf('%s:! userId doesnt match (is [%s] but expect [%s])',self::LOGKEY,bin2hex($userHandle),$uo->id)); + + try { + // process the get request. throws WebAuthnException if it fails + $this->webauthn->processGet( + base64_decode($request->clientDataJSON), + base64_decode($request->authenticatorData), + base64_decode($request->signature), + $uo->passkey['pk'], + $challenge, + NULL, + $this->userVerification === 'required' + ); + + Log::debug(sprintf('%s:= User logged in [%d]',self::LOGKEY,$uo->id)); + + Auth::login($uo); + + return [ + 'msg' => 'Login Successful', + 'success' => TRUE, + ]; + + } catch (WebAuthnException $e) { + Log::debug(sprintf('%s:= Passkey Failed for [%d] with msg [%s]',self::LOGKEY,$uo->id,$e->getMessage())); + + return [ + 'msg' => $e->getMessage(), + 'success' => FALSE, + ]; + } + } + + /** + * Start User Enrollment registration + * + * @param Request $request + * @return \stdClass|void + * @throws \lbuchs\WebAuthn\WebAuthnException + */ + public function register(Request $request) + { + Log::debug(sprintf('%s:- Register for user [%d]',self::LOGKEY,Auth::id()),['request'=>$request]); + + $this->init(); + + $createArgs = $this->webauthn->getCreateArgs( + hex2bin($this->userId), + $this->userName, + $this->userDisplayName, + $this->timeout, + $this->requireResidentKey, + $this->userVerification, + $this->crossPlatformAttachment, + $this->excludeCredentials, + ); + + // Save challenge to session. you have to deliver it to get() later. + session()->put('challenge',$x=$this->webauthn->getChallenge()); + Log::debug(sprintf('%s:- Challenge Stored [%s]',self::LOGKEY,$x)); + + return $createArgs; + } +} \ No newline at end of file diff --git a/src/PasskeyServiceProvider.php b/src/PasskeyServiceProvider.php new file mode 100644 index 0000000..caa9438 --- /dev/null +++ b/src/PasskeyServiceProvider.php @@ -0,0 +1,23 @@ +loadRoutesFrom(__DIR__.'/routes.php'); + $this->loadMigrationsFrom(__DIR__.'/database/migrations'); + } +} \ No newline at end of file diff --git a/src/database/migrations/2024_04_22_223358_user_passkey.php b/src/database/migrations/2024_04_22_223358_user_passkey.php new file mode 100644 index 0000000..c11d417 --- /dev/null +++ b/src/database/migrations/2024_04_22_223358_user_passkey.php @@ -0,0 +1,28 @@ +jsonb('passkey')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['passkey']); + }); + } +}; diff --git a/src/public/js/passkey.js b/src/public/js/passkey.js new file mode 100644 index 0000000..8cc55c2 --- /dev/null +++ b/src/public/js/passkey.js @@ -0,0 +1,225 @@ +/* + * Passkey Implementation + */ +let passkey_debug = false; + +/** + * Convert a ArrayBuffer to Base64 + * @param {ArrayBuffer} buffer + * @returns {String} + */ +function arrayBufferToBase64(buffer) { + let binary = ''; + let bytes = new Uint8Array(buffer); + let len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode( bytes[ i ] ); + } + return window.btoa(binary); +} + +/** + * convert RFC 1342-like base64 strings to array buffer + * @param {mixed} obj + * @returns {undefined} + */ +function recursiveBase64StrToArrayBuffer(obj) { + let prefix = '=?BINARY?B?'; + let suffix = '?='; + if (typeof obj === 'object') { + for (let key in obj) { + if (typeof obj[key] === 'string') { + let str = obj[key]; + if (str.substring(0, prefix.length) === prefix && str.substring(str.length - suffix.length) === suffix) { + str = str.substring(prefix.length, str.length - suffix.length); + + let binary_string = window.atob(str); + let len = binary_string.length; + let bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binary_string.charCodeAt(i); + } + obj[key] = bytes.buffer; + } + } else { + recursiveBase64StrToArrayBuffer(obj[key]); + } + } + } +} + +function passkey_check_browser() +{ + // check browser support + if ((! window.fetch) || (! navigator.credentials) || (! navigator.credentials.create)) + throw new Error('Browser not supported.'); + + /* + // Availability of `window.PublicKeyCredential` means WebAuthn is usable. + // `isUserVerifyingPlatformAuthenticatorAvailable` means the feature detection is usable. + // `isConditionalMediationAvailable` means the feature detection is usable. + if (window.PublicKeyCredential && + PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable && + PublicKeyCredential.isConditionalMediationAvailable) { + // Check if user verifying platform authenticator is available. + Promise.all([ + PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(), + PublicKeyCredential.isConditionalMediationAvailable(), + ]).then(results => { + if (results.every(r => r === true)) { + // Display "Create a new passkey" button + } + }); + } + */ + + if (passkey_debug) + console.log('Passkey: Browser OK'); + + return true; +} + +/** + * Register/Create a passkey for a user + */ +async function passkey_register(csrf_token,icon_dom,icon,icon_shell_success,icon_shell_fail) +{ + try { + if (! passkey_check_browser()) + return; + + // Change our icon so that it is obvious we are doing something + icon_dom.find('i').removeClass(icon).addClass('spinner-grow spinner-grow-sm'); + + // Get our arguments + var createArgs; + $.ajax({ + url: '/passkey/register', + type: 'GET', + dataType: 'json', + async: false, + cache: false, + success: function(data) { + if (passkey_debug) + console.log('Passkey: Get Register Success'); + + recursiveBase64StrToArrayBuffer(data); + createArgs = data; + }, + error: function(e,status,error) { + throw new Error(status || 'Unknown error occurred'); + } + }); + + // Create credentials + try { + const cred = await navigator.credentials.create(createArgs); + + const authenticatorAttestationResponse = { + id: cred.id, + rawId: arrayBufferToBase64(cred.rawId), + transports: cred.response.getTransports ? cred.response.getTransports() : null, + clientDataJSON: cred.response.clientDataJSON ? arrayBufferToBase64(cred.response.clientDataJSON) : null, + attestationObject: cred.response.attestationObject ? arrayBufferToBase64(cred.response.attestationObject) : null, + authenticatorAttachment: cred.authenticatorAttachment, + _token: csrf_token, + }; + + $.ajax({ + url: '/passkey/check', + type: 'POST', + data: authenticatorAttestationResponse, + cache: false, + success: function(data) { + if (passkey_debug) + console.log('Passkey: Registration Success'); + + icon_dom.find('i').addClass(icon).removeClass('spinner-grow spinner-grow-sm'); + icon_dom.removeClass('btn-outline-primary').addClass('btn-primary'); + }, + error: function(e,status,error) { + throw new Error(status || 'Unknown error occurred'); + } + }); + + } catch (status) { + if (passkey_debug) + console.log(status || 'Passkey: User Aborted Register'); + + // Restore the icon + icon_dom.find('i').addClass(icon).removeClass('spinner-grow spinner-grow-sm'); + + return; + } + + } catch (err) { + window.alert(err || 'An UNKNOWN error occurred?'); + } +} + +/** + * Check a passkey being presented + */ +async function passkey_check(csrf_token,redirect) +{ + if (passkey_debug) + console.log('Passkey: Check User Passkey'); + + try { + if (! passkey_check_browser()) + return; + + // Get our arguments + var getArgs; + $.ajax({ + url: '/passkey/get', + type: 'GET', + dataType: 'json', + async: false, + cache: false, + success: function(data) { + if (passkey_debug) + console.log('Passkey: Get Args Success'); + + recursiveBase64StrToArrayBuffer(data); + getArgs = data; + }, + error: function(e,status,error) { + throw new Error(status || 'Unknown error occurred'); + } + }); + + // check credentials with hardware + const cred = await navigator.credentials.get(getArgs); + + // create object for transmission to server + const authenticatorAttestationResponse = { + id: cred.rawId ? arrayBufferToBase64(cred.rawId) : null, + clientDataJSON: cred.response.clientDataJSON ? arrayBufferToBase64(cred.response.clientDataJSON) : null, + authenticatorData: cred.response.authenticatorData ? arrayBufferToBase64(cred.response.authenticatorData) : null, + signature: cred.response.signature ? arrayBufferToBase64(cred.response.signature) : null, + userHandle: cred.response.userHandle ? arrayBufferToBase64(cred.response.userHandle) : null, + _token: csrf_token + }; + + $.ajax({ + url: '/passkey/process', + type: 'POST', + data: authenticatorAttestationResponse, + cache: false, + success: function(data) { + if (passkey_debug) + console.log('Passkey: Process Success'); + + // Direct to the home page + window.location.href = (redirect !== undefined) ? redirect : '/'; + }, + error: function(e,status,error) { + throw new Error(status || 'Unknown error occurred'); + } + }); + + } catch (err) { + window.alert(err || 'An UNKNOWN error occurred?'); + } +} \ No newline at end of file diff --git a/src/routes.php b/src/routes.php new file mode 100644 index 0000000..f5481be --- /dev/null +++ b/src/routes.php @@ -0,0 +1,13 @@ + ['web']], function () { + Route::get('passkey/get',[PasskeyController::class,'args']); + Route::post('passkey/process',[PasskeyController::class,'process']); + + Route::middleware(['auth','verified','activeuser'])->group(function () { + Route::post('passkey/check',[PasskeyController::class,'check']); + Route::get('passkey/register',[PasskeyController::class,'register']); + }); +}); \ No newline at end of file