mirror of
https://github.com/Bubka/2FAuth.git
synced 2025-05-18 21:20:51 +02:00
Set up the Webauthn service to handle webauthn login & registration
This commit is contained in:
parent
ba96358801
commit
2cd5b40ae3
95
resources/js_vue3/services/webauthn/identifyAuthenticationError.js
vendored
Normal file
95
resources/js_vue3/services/webauthn/identifyAuthenticationError.js
vendored
Normal file
@ -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'
|
||||
}
|
||||
}
|
160
resources/js_vue3/services/webauthn/identifyRegistrationError.js
vendored
Normal file
160
resources/js_vue3/services/webauthn/identifyRegistrationError.js
vendored
Normal file
@ -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'
|
||||
}
|
||||
}
|
15
resources/js_vue3/services/webauthn/isValidDomain.js
vendored
Normal file
15
resources/js_vue3/services/webauthn/isValidDomain.js
vendored
Normal file
@ -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)
|
||||
);
|
||||
}
|
51
resources/js_vue3/services/webauthn/webauthnAbortService.js
vendored
Normal file
51
resources/js_vue3/services/webauthn/webauthnAbortService.js
vendored
Normal file
@ -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();
|
202
resources/js_vue3/services/webauthn/webauthnService.js
vendored
Normal file
202
resources/js_vue3/services/webauthn/webauthnService.js
vendored
Normal file
@ -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();
|
Loading…
Reference in New Issue
Block a user