Enhance webauthn error handling at registration

This commit is contained in:
Bubka 2023-09-14 11:29:51 +02:00
parent 935e560552
commit acc5d7170c
8 changed files with 281 additions and 53 deletions

View File

@ -41,7 +41,8 @@
<script>
import Form from './../../components/Form'
import WebAuthn from './../../components/WebAuthn'
import WebauthnService from './../../webauthn/WebauthnService'
import { webauthnAbortService } from '../../webauthn/webauthnAbortService'
export default {
data(){
@ -55,7 +56,7 @@
isBusy: false,
showWebauthn: this.$root.userPreferences.useWebauthnOnly,
csrfRefresher: null,
webauthn: new WebAuthn()
webauthn: new WebauthnService()
}
},
@ -131,7 +132,7 @@
this.$notify({ type: 'is-danger', text: this.$t('errors.not_allowed_operation') })
}
else if (error.name == 'NotSupportedError') {
this.$notify({ type: 'is-danger', text: this.$t('errors.unsupported_operation') })
this.$notify({ type: 'is-danger', text: this.$t('errors.no_authenticator_support_specified_algorithms') })
}
else if (error.name == 'InvalidStateError') {
this.$notify({ type: 'is-danger', text: this.$t('auth.webauthn.unknown_device') })

View File

@ -40,7 +40,9 @@
<script>
import Form from './../../components/Form'
import WebAuthn from './../../components/WebAuthn'
import WebauthnService from './../../webauthn/WebauthnService'
import { webauthnAbortService } from './../../webauthn/webauthnAbortService'
import { identifyRegistrationError } from './../../webauthn/identifyRegistrationError'
export default {
data(){
@ -56,8 +58,7 @@
}),
showWebauthnRegistration: false,
deviceRegistered: false,
deviceId : null,
webauthn: new WebAuthn()
deviceId : null
}
},
@ -87,6 +88,8 @@
* Register a new security device
*/
async registerWebauthnDevice() {
let webauthnService = new WebauthnService()
// Check https context
if (!window.isSecureContext) {
this.$notify({ type: 'is-danger', text: this.$t('errors.https_required') })
@ -94,29 +97,29 @@
}
// Check browser support
if (this.webauthn.doesntSupportWebAuthn) {
if (webauthnService.doesntSupportWebAuthn) {
this.$notify({ type: 'is-danger', text: this.$t('errors.browser_does_not_support_webauthn') })
return false
}
const registerOptions = await this.axios.post('/webauthn/register/options').then(res => res.data)
const publicKey = this.webauthn.parseIncomingServerOptions(registerOptions)
let bufferedCredentials
const publicKey = webauthnService.parseIncomingServerOptions(registerOptions)
let options = { publicKey }
options.signal = webauthnAbortService.createNewAbortSignal()
let bufferedCredentials
try {
bufferedCredentials = await navigator.credentials.create({ publicKey })
bufferedCredentials = await navigator.credentials.create(options)
}
catch (error) {
if (error.name == 'AbortError') {
this.$notify({ type: 'is-warning', text: this.$t('errors.aborted_by_user') })
}
else if (error.name == 'NotAllowedError' || 'InvalidStateError') {
this.$notify({ type: 'is-danger', text: this.$t('errors.security_device_unsupported') })
}
const webauthnError = identifyRegistrationError(error, options)
this.$notify({ type: webauthnError.type, text: this.$t(webauthnError.phrase) })
return false
}
const publicKeyCredential = this.webauthn.parseOutgoingCredentials(bufferedCredentials);
const publicKeyCredential = webauthnService.parseOutgoingCredentials(bufferedCredentials);
this.axios.post('/webauthn/register', publicKeyCredential, {returnError: true})
.then(response => {

View File

@ -67,7 +67,9 @@
<script>
import Form from './../../components/Form'
import WebAuthn from './../../components/WebAuthn'
import WebauthnService from './../../webauthn/WebauthnService'
import { webauthnAbortService } from './../../webauthn/webauthnAbortService'
import { identifyRegistrationError } from './../../webauthn/identifyRegistrationError'
export default {
data(){
@ -77,8 +79,7 @@
}),
credentials: [],
isFetching: false,
isRemoteUser: false,
webauthn: new WebAuthn()
isRemoteUser: false
}
},
@ -135,6 +136,7 @@
* Register a new security device
*/
async register() {
let webauthnService = new WebauthnService()
if (this.isRemoteUser) {
this.$notify({ type: 'is-warning', text: this.$t('errors.unsupported_with_reverseproxy') })
@ -148,38 +150,29 @@
}
// Check browser support
if (this.webauthn.doesntSupportWebAuthn) {
if (webauthnService.doesntSupportWebAuthn) {
this.$notify({ type: 'is-danger', text: this.$t('errors.browser_does_not_support_webauthn') })
return false
}
const registerOptions = await this.axios.post('/webauthn/register/options').then(res => res.data)
const publicKey = this.webauthn.parseIncomingServerOptions(registerOptions)
let bufferedCredentials
const publicKey = webauthnService.parseIncomingServerOptions(registerOptions)
let options = { publicKey }
options.signal = webauthnAbortService.createNewAbortSignal()
let bufferedCredentials
try {
bufferedCredentials = await navigator.credentials.create({ publicKey })
bufferedCredentials = await navigator.credentials.create(options)
}
catch (error) {
if (error.name == 'AbortError') {
this.$notify({ type: 'is-warning', text: this.$t('errors.aborted_by_user') })
}
else if (error.name == 'SecurityError') {
this.$notify({ type: 'is-danger', text: this.$t('errors.security_error_check_rpid') })
}
else if (error.name == 'InvalidStateError') {
this.$notify({ type: 'is-danger', text: this.$t('errors.security_device_unsupported') })
}
else if (error.name == 'NotAllowedError') {
this.$notify({ type: 'is-danger', text: this.$t('errors.not_allowed_operation') })
}
else if (error.name == 'NotSupportedError') {
this.$notify({ type: 'is-danger', text: this.$t('errors.unsupported_operation') })
}
const webauthnError = identifyRegistrationError(error, options)
this.$notify({ type: webauthnError.type, text: this.$t(webauthnError.phrase) })
return false
}
const publicKeyCredential = this.webauthn.parseOutgoingCredentials(bufferedCredentials);
const publicKeyCredential = webauthnService.parseOutgoingCredentials(bufferedCredentials);
this.axios.post('/webauthn/register', publicKeyCredential, {returnError: true})
.then(response => {

View 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/webauthn/isValidDomain.js vendored Normal file
View 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)
);
}

View 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();

View File

@ -23,10 +23,10 @@
* SOFTWARE.
*/
export default class WebAuthn {
export default class WebauthnService {
/**
* Create a new WebAuthn instance.
* Create a new WebauthnService instance.
*
*/
constructor () {
@ -40,12 +40,12 @@ export default class WebAuthn {
* @returns {Object}
*/
parseIncomingServerOptions(publicKey) {
publicKey.challenge = WebAuthn.uint8Array(publicKey.challenge);
publicKey.challenge = WebauthnService.uint8Array(publicKey.challenge);
if ('user' in publicKey) {
publicKey.user = {
...publicKey.user,
id: WebAuthn.uint8Array(publicKey.user.id)
id: WebauthnService.uint8Array(publicKey.user.id)
};
}
@ -56,7 +56,7 @@ export default class WebAuthn {
.filter(key => key in publicKey)
.forEach(key => {
publicKey[key] = publicKey[key].map(data => {
return {...data, id: WebAuthn.uint8Array(data.id)};
return {...data, id: WebauthnService.uint8Array(data.id)};
});
});
@ -74,7 +74,7 @@ export default class WebAuthn {
let parseCredentials = {
id: credentials.id,
type: credentials.type,
rawId: WebAuthn.arrayToBase64String(credentials.rawId),
rawId: WebauthnService.arrayToBase64String(credentials.rawId),
response: {}
};
@ -86,7 +86,7 @@ export default class WebAuthn {
"userHandle"
]
.filter(key => key in credentials.response)
.forEach(key => parseCredentials.response[key] = WebAuthn.arrayToBase64String(credentials.response[key]));
.forEach(key => parseCredentials.response[key] = WebauthnService.arrayToBase64String(credentials.response[key]));
return parseCredentials;
}
@ -101,7 +101,7 @@ export default class WebAuthn {
*/
static uint8Array(input, useAtob = false) {
return Uint8Array.from(
useAtob ? atob(input) : WebAuthn.base64UrlDecode(input), c => c.charCodeAt(0)
useAtob ? atob(input) : WebauthnService.base64UrlDecode(input), c => c.charCodeAt(0)
);
}
@ -112,7 +112,7 @@ export default class WebAuthn {
* @param arrayBuffer {ArrayBuffer|Uint8Array}
* @returns {string}
*/
static arrayToBase64String(arrayBuffer) {
static arrayToBase64String(arrayBuffer) {
return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
}
@ -123,7 +123,7 @@ export default class WebAuthn {
* @param input {string}
* @returns {string|Iterable}
*/
static base64UrlDecode(input) {
static base64UrlDecode(input) {
input = input.replace(/-/g, "+").replace(/_/g, "/");
const pad = input.length % 4;
@ -146,7 +146,7 @@ export default class WebAuthn {
* @returns {boolean}
*/
static supportsWebAuthn() {
return typeof PublicKeyCredential != "undefined";
return (window?.PublicKeyCredential !== undefined && typeof window.PublicKeyCredential === 'function');
}

View File

@ -33,11 +33,16 @@
'https_required' => 'HTTPS context required',
'browser_does_not_support_webauthn' => 'Your device does not support webauthn. Try again later using a more modern browser',
'aborted_by_user' => 'Aborted by user',
'security_device_unsupported' => 'Unsupported or in use device',
'security_device_already_registered' => 'Device already registered',
'not_allowed_operation' => 'Operation not allowed',
'unsupported_operation' => 'Unsupported operation',
'no_authenticator_support_specified_algorithms' => 'No authenticators support specified algorithms',
'authenticator_missing_discoverable_credential_support' => 'Authenticator missing discoverable credential support',
'authenticator_missing_user_verification_support' => 'Authenticator missing user verification support',
'unknown_error' => 'Unknown error',
'security_error_check_rpid' => 'Security error<br/>Check your WEBAUTHN_ID env var',
'2fauth_has_not_a_valid_domain' => '2FAuth\'s domain is not a valid domain',
'user_id_not_between_1_64' => 'User ID was not between 1 and 64 chars',
'no_entry_was_of_type_public_key' => 'No entry was of type "public-key"',
'unsupported_with_reverseproxy' => 'Not applicable when using an auth proxy',
'user_deletion_failed' => 'User account deletion failed, no data have been deleted',
'auth_proxy_failed' => 'Proxy authentication failed',