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

View File

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

View File

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

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. * SOFTWARE.
*/ */
export default class WebAuthn { export default class WebauthnService {
/** /**
* Create a new WebAuthn instance. * Create a new WebauthnService instance.
* *
*/ */
constructor () { constructor () {
@ -40,12 +40,12 @@ export default class WebAuthn {
* @returns {Object} * @returns {Object}
*/ */
parseIncomingServerOptions(publicKey) { parseIncomingServerOptions(publicKey) {
publicKey.challenge = WebAuthn.uint8Array(publicKey.challenge); publicKey.challenge = WebauthnService.uint8Array(publicKey.challenge);
if ('user' in publicKey) { if ('user' in publicKey) {
publicKey.user = { publicKey.user = {
...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) .filter(key => key in publicKey)
.forEach(key => { .forEach(key => {
publicKey[key] = publicKey[key].map(data => { 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 = { let parseCredentials = {
id: credentials.id, id: credentials.id,
type: credentials.type, type: credentials.type,
rawId: WebAuthn.arrayToBase64String(credentials.rawId), rawId: WebauthnService.arrayToBase64String(credentials.rawId),
response: {} response: {}
}; };
@ -86,7 +86,7 @@ export default class WebAuthn {
"userHandle" "userHandle"
] ]
.filter(key => key in credentials.response) .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; return parseCredentials;
} }
@ -101,7 +101,7 @@ export default class WebAuthn {
*/ */
static uint8Array(input, useAtob = false) { static uint8Array(input, useAtob = false) {
return Uint8Array.from( return Uint8Array.from(
useAtob ? atob(input) : WebAuthn.base64UrlDecode(input), c => c.charCodeAt(0) useAtob ? atob(input) : WebauthnService.base64UrlDecode(input), c => c.charCodeAt(0)
); );
} }
@ -146,7 +146,7 @@ export default class WebAuthn {
* @returns {boolean} * @returns {boolean}
*/ */
static supportsWebAuthn() { 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', 'https_required' => 'HTTPS context required',
'browser_does_not_support_webauthn' => 'Your device does not support webauthn. Try again later using a more modern browser', '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', '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', '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', 'unknown_error' => 'Unknown error',
'security_error_check_rpid' => 'Security error<br/>Check your WEBAUTHN_ID env var', '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', 'unsupported_with_reverseproxy' => 'Not applicable when using an auth proxy',
'user_deletion_failed' => 'User account deletion failed, no data have been deleted', 'user_deletion_failed' => 'User account deletion failed, no data have been deleted',
'auth_proxy_failed' => 'Proxy authentication failed', 'auth_proxy_failed' => 'Proxy authentication failed',