From 2cd5b40ae37fac9b47a05c901e11e7cff3b11da4 Mon Sep 17 00:00:00 2001 From: Bubka <858858+Bubka@users.noreply.github.com> Date: Thu, 5 Oct 2023 14:07:14 +0200 Subject: [PATCH] Set up the Webauthn service to handle webauthn login & registration --- .../webauthn/identifyAuthenticationError.js | 95 ++++++++ .../webauthn/identifyRegistrationError.js | 160 ++++++++++++++ .../services/webauthn/isValidDomain.js | 15 ++ .../services/webauthn/webauthnAbortService.js | 51 +++++ .../services/webauthn/webauthnService.js | 202 ++++++++++++++++++ 5 files changed, 523 insertions(+) create mode 100644 resources/js_vue3/services/webauthn/identifyAuthenticationError.js create mode 100644 resources/js_vue3/services/webauthn/identifyRegistrationError.js create mode 100644 resources/js_vue3/services/webauthn/isValidDomain.js create mode 100644 resources/js_vue3/services/webauthn/webauthnAbortService.js create mode 100644 resources/js_vue3/services/webauthn/webauthnService.js diff --git a/resources/js_vue3/services/webauthn/identifyAuthenticationError.js b/resources/js_vue3/services/webauthn/identifyAuthenticationError.js new file mode 100644 index 00000000..5fe7f59c --- /dev/null +++ b/resources/js_vue3/services/webauthn/identifyAuthenticationError.js @@ -0,0 +1,95 @@ +/** + * MIT License + * + * Copyright (c) 2023 Bubka - https://github.com/Bubka/2FAuth + * Copyright (c) 2020 Matthew Miller - https://github.com/MasterKale/SimpleWebAuthn + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +import { isValidDomain } from './isValidDomain'; + +/** + * Attempt to intuit _why_ an error was raised after calling `navigator.credentials.get()` + */ +export function identifyAuthenticationError(error, options) { + const { publicKey } = options; + + if (error.name === 'AbortError') { + if (options.signal instanceof AbortSignal) { + // Authentication ceremony was sent an abort signal + // https://www.w3.org/TR/webauthn-2/#sctn-createCredential (Step 16) + + return { + phrase: 'errors.aborted_by_user', + type: 'is-warning' + } + } + + } else if (error.name === 'NotAllowedError') { + /** + * Pass the error directly through. Platforms are overloading this error beyond what the spec + * defines and we don't want to overwrite potentially useful error messages. + */ + + return { + phrase: 'errors.not_allowed_operation', + type: 'is-danger' + } + + } else if (error.name === 'SecurityError') { + + const effectiveDomain = window.location.hostname; + + if (!isValidDomain(effectiveDomain)) { + // The current location domain is not a valid domain + // https://www.w3.org/TR/webauthn-2/#sctn-discover-from-external-source (Step 5) + + return { + phrase: 'errors.2fauth_has_not_a_valid_domain', + type: 'is-danger' + } + + } else if (publicKey.rpId !== effectiveDomain) { + // The RP ID "${publicKey.rpId}" is invalid for this domain + // // https://www.w3.org/TR/webauthn-2/#sctn-discover-from-external-source (Step 6) + + return { + phrase: 'errors.security_error_check_rpid', + type: 'is-danger' + } + } + + } else if (error.name === 'UnknownError') { + // The authenticator was unable to process the specified options, or could not create a new assertion signature + // https://www.w3.org/TR/webauthn-2/#sctn-op-get-assertion (Step 1) + // https://www.w3.org/TR/webauthn-2/#sctn-op-get-assertion (Step 12) + + return { + phrase: 'errors.unknown_error', + type: 'is-danger' + } + } + + return { + phrase: 'errors.unknown_error', + type: 'is-danger' + } +} \ No newline at end of file diff --git a/resources/js_vue3/services/webauthn/identifyRegistrationError.js b/resources/js_vue3/services/webauthn/identifyRegistrationError.js new file mode 100644 index 00000000..f7691adb --- /dev/null +++ b/resources/js_vue3/services/webauthn/identifyRegistrationError.js @@ -0,0 +1,160 @@ +/** + * MIT License + * + * Copyright (c) 2023 Bubka - https://github.com/Bubka/2FAuth + * Copyright (c) 2020 Matthew Miller - https://github.com/MasterKale/SimpleWebAuthn + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ + +import { isValidDomain } from './isValidDomain'; + +/** + * Attempt to intuit _why_ an error was raised after calling `navigator.credentials.create()` + */ +export function identifyRegistrationError(error, options) { + const { publicKey } = options; + + if (error.name === 'AbortError') { + if (options.signal instanceof AbortSignal) { + // Registration ceremony was sent an abort signal + // https://www.w3.org/TR/webauthn-2/#sctn-createCredential (Step 16) + + return { + phrase: 'errors.aborted_by_user', + type: 'is-warning' + } + } + + } else if (error.name === 'ConstraintError') { + if (publicKey.authenticatorSelection?.requireResidentKey === true) { + // Discoverable credentials were required but no available authenticator supported it + // https://www.w3.org/TR/webauthn-2/#sctn-op-make-cred (Step 4) + + return { + phrase: 'errors.authenticator_missing_discoverable_credential_support', + type: 'is-danger' + } + + } else if (publicKey.authenticatorSelection?.userVerification === 'required') { + // User verification was required but no available authenticator supported it + // https://www.w3.org/TR/webauthn-2/#sctn-op-make-cred (Step 5) + + return { + phrase: 'errors.authenticator_missing_user_verification_support', + type: 'is-danger' + } + } + + } else if (error.name === 'InvalidStateError') { + // The authenticator was previously registered + // https://www.w3.org/TR/webauthn-2/#sctn-createCredential (Step 20) + // https://www.w3.org/TR/webauthn-2/#sctn-op-make-cred (Step 3) + + return { + phrase: 'errors.security_device_already_registered', + type: 'is-danger' + } + + } else if (error.name === 'NotAllowedError') { + /** + * Pass the error directly through. Platforms are overloading this error beyond what the spec + * defines and we don't want to overwrite potentially useful error messages. + */ + + return { + phrase: 'errors.not_allowed_operation', + type: 'is-danger' + } + + } else if (error.name === 'NotSupportedError') { + + const validPubKeyCredParams = publicKey.pubKeyCredParams.filter( + (param) => param.type === 'public-key', + ); + + if (validPubKeyCredParams.length === 0) { + // No entry in pubKeyCredParams was of type "public-key" + // https://www.w3.org/TR/webauthn-2/#sctn-createCredential (Step 10) + + return { + phrase: 'errors.no_entry_was_of_type_public_key', + type: 'is-danger' + } + } + + // No available authenticator supported any of the specified pubKeyCredParams algorithms + // https://www.w3.org/TR/webauthn-2/#sctn-op-make-cred (Step 2) + + return { + phrase: 'errors.no_authenticator_support_specified_algorithms', + type: 'is-danger' + } + + } else if (error.name === 'SecurityError') { + + const effectiveDomain = window.location.hostname; + + if (!isValidDomain(effectiveDomain)) { + // The current location domain is not a valid domain + // https://www.w3.org/TR/webauthn-2/#sctn-createCredential (Step 7) + + return { + phrase: 'errors.2fauth_has_not_a_valid_domain', + type: 'is-danger' + } + + } else if (publicKey.rp.id !== effectiveDomain) { + // The RP ID "${publicKey.rp.id}" is invalid for this domain + // https://www.w3.org/TR/webauthn-2/#sctn-createCredential (Step 8) + + return { + phrase: 'errors.security_error_check_rpid', + type: 'is-danger' + } + } + + } else if (error.name === 'TypeError') { + if (publicKey.user.id.byteLength < 1 || publicKey.user.id.byteLength > 64) { + // User ID was not between 1 and 64 characters + // https://www.w3.org/TR/webauthn-2/#sctn-createCredential (Step 5) + + return { + phrase: 'errors.user_id_not_between_1_64', + type: 'is-danger' + } + } + + } else if (error.name === 'UnknownError') { + // The authenticator was unable to process the specified options, or could not create a new credential + // https://www.w3.org/TR/webauthn-2/#sctn-op-make-cred (Step 1) + // https://www.w3.org/TR/webauthn-2/#sctn-op-make-cred (Step 8) + + return { + phrase: 'errors.unknown_error', + type: 'is-danger' + } + } + + return { + phrase: 'errors.unknown_error', + type: 'is-danger' + } +} diff --git a/resources/js_vue3/services/webauthn/isValidDomain.js b/resources/js_vue3/services/webauthn/isValidDomain.js new file mode 100644 index 00000000..b02e8522 --- /dev/null +++ b/resources/js_vue3/services/webauthn/isValidDomain.js @@ -0,0 +1,15 @@ +/** + * A simple test to determine if a hostname is a properly-formatted domain name + * + * A "valid domain" is defined here: https://url.spec.whatwg.org/#valid-domain + * + * Regex sourced from here: + * https://www.oreilly.com/library/view/regular-expressions-cookbook/9781449327453/ch08s15.html + */ +export function isValidDomain(hostname) { + return ( + // Consider localhost valid as well since it's okay wrt Secure Contexts + hostname === 'localhost' || + /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i.test(hostname) + ); +} \ No newline at end of file diff --git a/resources/js_vue3/services/webauthn/webauthnAbortService.js b/resources/js_vue3/services/webauthn/webauthnAbortService.js new file mode 100644 index 00000000..b8aa9ad0 --- /dev/null +++ b/resources/js_vue3/services/webauthn/webauthnAbortService.js @@ -0,0 +1,51 @@ +/** + * MIT License + * + * Copyright (c) 2020 Matthew Miller - https://github.com/MasterKale/SimpleWebAuthn + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * A way to cancel an existing WebAuthn request, for example to cancel a + * WebAuthn autofill authentication request for a manual authentication attempt. + */ +class WebauthnAbortService { + controller; + + /** + * Prepare an abort signal that will help support multiple auth attempts without needing to + * reload the page + */ + createNewAbortSignal() { + // Abort any existing calls to navigator.credentials.create() or navigator.credentials.get() + if (this.controller) { + const abortError = new Error( + 'Cancelling existing WebAuthn API call for new one', + ); + abortError.name = 'AbortError'; + this.controller.abort(abortError); + } + + const newController = new AbortController(); + + this.controller = newController; + return newController.signal; + } +} + +export const webauthnAbortService = new WebauthnAbortService(); \ No newline at end of file diff --git a/resources/js_vue3/services/webauthn/webauthnService.js b/resources/js_vue3/services/webauthn/webauthnService.js new file mode 100644 index 00000000..02fb9783 --- /dev/null +++ b/resources/js_vue3/services/webauthn/webauthnService.js @@ -0,0 +1,202 @@ +/** + * MIT License + * + * Copyright (c) 2023 Bubka - https://github.com/Bubka/2FAuth + * Copyright (c) Italo Israel Baeza Cabrera - https://github.com/Laragear/WebAuthn + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { httpClientFactory } from '@/services/httpClientFactory' +import { webauthnAbortService } from '@/services/webauthn/webauthnAbortService' +import { identifyRegistrationError } from '@/services/webauthn/identifyRegistrationError' + +const webClient = httpClientFactory('web') + +class WebauthnService { + + async register() { + let err = { + webauthn: true, + type: 'is-danger', + message: '' + } + + // Check https context + if (!window.isSecureContext) { + err.message = 'errors.https_required' + return Promise.reject(err) + } + + // Check browser support + if (! WebauthnService.supportsWebAuthn) { + err.message = 'errors.browser_does_not_support_webauthn' + return Promise.reject(err) + } + + const registerOptions = await webClient.post('/webauthn/register/options').then(response => response.data) + const publicKey = WebauthnService.parseIncomingServerOptions(registerOptions) + + let options = { publicKey } + options.signal = webauthnAbortService.createNewAbortSignal() + + let bufferedCredentials + try { + bufferedCredentials = await navigator.credentials.create(options) + } + catch (error) { + const webauthnError = identifyRegistrationError(error, options) + // console.log(webauthnError) + return Promise.reject({ + webauthn: true, + type: webauthnError.type, + message: webauthnError.phrase + }) + } + + const publicKeyCredential = WebauthnService.parseOutgoingCredentials(bufferedCredentials); + + return webClient.post('/webauthn/register', publicKeyCredential, {returnError: true}) + } + + /** + * Parses the Public Key Options received from the Server for the browser. + * + * @param publicKey {Object} + * @returns {Object} + */ + static parseIncomingServerOptions(publicKey) { + publicKey.challenge = WebauthnService.uint8Array(publicKey.challenge); + + if ('user' in publicKey) { + publicKey.user = { + ...publicKey.user, + id: WebauthnService.uint8Array(publicKey.user.id) + }; + } + + [ + "excludeCredentials", + "allowCredentials" + ] + .filter(key => key in publicKey) + .forEach(key => { + publicKey[key] = publicKey[key].map(data => { + return {...data, id: WebauthnService.uint8Array(data.id)}; + }); + }); + + return publicKey; + } + + /** + * Parses the outgoing credentials from the browser to the server. + * + * @param credentials {Credential|PublicKeyCredential} + * @return {{response: {string}, rawId: string, id: string, type: string}} + */ + static parseOutgoingCredentials(credentials) { + let parseCredentials = { + id: credentials.id, + type: credentials.type, + rawId: WebauthnService.arrayToBase64String(credentials.rawId), + response: {} + }; + + [ + "clientDataJSON", + "attestationObject", + "authenticatorData", + "signature", + "userHandle" + ] + .filter(key => key in credentials.response) + .forEach(key => parseCredentials.response[key] = WebauthnService.arrayToBase64String(credentials.response[key])); + + return parseCredentials; + } + + /** + * Transform a string into Uint8Array instance. + * + * @param input {string} + * @param useAtob {boolean} + * @returns {Uint8Array} + */ + static uint8Array(input, useAtob = false) { + return Uint8Array.from( + useAtob ? atob(input) : WebauthnService.base64UrlDecode(input), c => c.charCodeAt(0) + ); + } + + /** + * Encodes an array of bytes to a BASE64 URL string + * + * @param arrayBuffer {ArrayBuffer|Uint8Array} + * @returns {string} + */ + static arrayToBase64String(arrayBuffer) { + return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); + } + + + /** + * Decodes a BASE64 URL string into a normal string. + * + * @param input {string} + * @returns {string|Iterable} + */ + static base64UrlDecode(input) { + 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 atob(input); + } + + + /** + * Checks if the browser supports WebAuthn. + * + * @returns {boolean} + */ + static supportsWebAuthn() { + return (window?.PublicKeyCredential !== undefined && typeof window.PublicKeyCredential === 'function'); + } + + + /** + * Checks if the browser doesn't support WebAuthn. + * + * @returns {boolean} + */ + // static doesntSupportWebAuthn() { + // return !WebauthnService.supportsWebAuthn(); + // } +} + +export const webauthnService = new WebauthnService(); \ No newline at end of file