From cc698c616a74806f43647c786ae20d72a1926f00 Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Mon, 15 Jul 2024 18:39:30 +0200 Subject: [PATCH] Updated web authentication methods in controller Refactored the controller to better handle web authentication. The changes introduce checks for web authentication support before allowing sign-in or sign-up actions. Also, error handling and event dispatching have been enhanced for more robust operation. Scripts related to data fetching and processing have been moved into private methods for improved code organization. --- src/stimulus/assets/dist/controller.d.ts | 24 ++- src/stimulus/assets/dist/controller.js | 180 +++++++++++++-------- src/stimulus/assets/src/controller.ts | 192 +++++++++++++++-------- 3 files changed, 260 insertions(+), 136 deletions(-) diff --git a/src/stimulus/assets/dist/controller.d.ts b/src/stimulus/assets/dist/controller.d.ts index b4534c731..fac7c953b 100644 --- a/src/stimulus/assets/dist/controller.d.ts +++ b/src/stimulus/assets/dist/controller.d.ts @@ -47,6 +47,15 @@ export default class extends Controller { type: BooleanConstructor; default: boolean; }; + requestHeaders: { + type: ObjectConstructor; + default: { + 'Content-Type': string; + Accept: string; + mode: string; + credentials: string; + }; + }; }; readonly requestResultUrlValue: string; readonly requestOptionsUrlValue: string; @@ -61,10 +70,17 @@ export default class extends Controller { readonly residentKeyFieldValue: string; readonly authenticatorAttachmentFieldValue: string; readonly useBrowserAutofillValue: boolean; - connect(): void; + readonly requestHeadersValue: object; + connect: () => Promise; signin(event: Event): Promise; + private _processSignin; signup(event: Event): Promise; - _dispatchEvent(name: string, payload: any): void; - fetch(method: string, url: string, body: string): Promise; - _getData(): any; + private _dispatchEvent; + private _getData; + private _getPublicKeyCredentialRequestOptions; + private _getPublicKeyCredentialCreationOptions; + private _getOptions; + private _getAttestationResponse; + private _getAssertionResponse; + private _getResult; } diff --git a/src/stimulus/assets/dist/controller.js b/src/stimulus/assets/dist/controller.js index 5afeee2ea..eecb4de5e 100644 --- a/src/stimulus/assets/dist/controller.js +++ b/src/stimulus/assets/dist/controller.js @@ -1,94 +1,89 @@ import { Controller } from '@hotwired/stimulus'; -import { startAuthentication, startRegistration } from '@simplewebauthn/browser'; +import { browserSupportsWebAuthnAutofill, browserSupportsWebAuthn, startAuthentication, startRegistration } from '@simplewebauthn/browser'; class default_1 extends Controller { - connect() { - var _a, _b; - const options = { - requestResultUrl: this.requestResultUrlValue, - requestOptionsUrl: this.requestOptionsUrlValue, - requestSuccessRedirectUri: (_a = this.requestSuccessRedirectUriValue) !== null && _a !== void 0 ? _a : null, - creationResultUrl: this.creationResultUrlValue, - creationOptionsUrl: this.creationOptionsUrlValue, - creationSuccessRedirectUri: (_b = this.creationSuccessRedirectUriValue) !== null && _b !== void 0 ? _b : null, + constructor() { + super(...arguments); + this.connect = async () => { + var _a, _b; + const options = { + requestResultUrl: this.requestResultUrlValue, + requestOptionsUrl: this.requestOptionsUrlValue, + requestSuccessRedirectUri: (_a = this.requestSuccessRedirectUriValue) !== null && _a !== void 0 ? _a : null, + creationResultUrl: this.creationResultUrlValue, + creationOptionsUrl: this.creationOptionsUrlValue, + creationSuccessRedirectUri: (_b = this.creationSuccessRedirectUriValue) !== null && _b !== void 0 ? _b : null, + }; + this._dispatchEvent('webauthn:connect', { options }); + const supportAutofill = await browserSupportsWebAuthnAutofill(); + if (supportAutofill && this.useBrowserAutofillValue) { + const optionsResponseJson = await this._getPublicKeyCredentialRequestOptions({}); + if (!optionsResponseJson) { + return; + } + this._processSignin(optionsResponseJson, true); + } }; - this._dispatchEvent('webauthn:connect', { options }); } async signin(event) { + if (!browserSupportsWebAuthn()) { + this._dispatchEvent('webauthn:unsupported', {}); + return; + } event.preventDefault(); - const data = this._getData(); - this._dispatchEvent('webauthn:request:options', { data }); - const resp = await this.fetch('POST', this.requestOptionsUrlValue, JSON.stringify(data)); - const respJson = await resp.response; - const asseResp = await startAuthentication(respJson, this.useBrowserAutofillValue); - const verificationResp = await this.fetch('POST', this.requestResultUrlValue, JSON.stringify(asseResp)); - const verificationJSON = await verificationResp.response; - this._dispatchEvent('webauthn:request:response', { response: asseResp }); - if (verificationJSON && verificationJSON.errorMessage === '') { - this._dispatchEvent('webauthn:request:success', verificationJSON); - if (this.requestSuccessRedirectUriValue) { + const optionsResponseJson = await this._getPublicKeyCredentialRequestOptions(null); + if (!optionsResponseJson) { + return; + } + this._processSignin(optionsResponseJson, false); + } + async _processSignin(optionsResponseJson, useBrowserAutofill) { + try { + const authenticatorResponse = await startAuthentication(optionsResponseJson, useBrowserAutofill); + this._dispatchEvent('webauthn:authenticator:response', { response: authenticatorResponse }); + const assertionResponse = await this._getAssertionResponse(authenticatorResponse); + if (assertionResponse !== false && this.requestSuccessRedirectUriValue) { window.location.replace(this.requestSuccessRedirectUriValue); } } - else { - this._dispatchEvent('webauthn:request:failure', verificationJSON.errorMessage); + catch (e) { + this._dispatchEvent('webauthn:assertion:failure', {}); + return; } } async signup(event) { - event.preventDefault(); - const data = this._getData(); - this._dispatchEvent('webauthn:creation:options', { data }); - const resp = await this.fetch('POST', this.creationOptionsUrlValue, JSON.stringify(data)); - const respJson = await resp.response; - if (respJson.excludeCredentials === undefined) { - respJson.excludeCredentials = []; - } - const attResp = await startRegistration(respJson); - this._dispatchEvent('webauthn:creation:response', { response: attResp }); - const verificationResp = await this.fetch('POST', this.creationResultUrlValue, JSON.stringify(attResp)); - const verificationJSON = await verificationResp.response; - if (verificationJSON && verificationJSON.errorMessage === '') { - this._dispatchEvent('webauthn:creation:success', verificationJSON); - if (this.creationSuccessRedirectUriValue) { + try { + if (!browserSupportsWebAuthn()) { + this._dispatchEvent('webauthn:unsupported', {}); + return; + } + event.preventDefault(); + const optionsResponseJson = await this._getPublicKeyCredentialCreationOptions(null); + if (!optionsResponseJson) { + return; + } + const authenticatorResponse = await startRegistration(optionsResponseJson); + this._dispatchEvent('webauthn:authenticator:response', { response: authenticatorResponse }); + const attestationResponseJSON = await this._getAttestationResponse(authenticatorResponse); + if (attestationResponseJSON !== false && this.creationSuccessRedirectUriValue) { window.location.replace(this.creationSuccessRedirectUriValue); } } - else { - this._dispatchEvent('webauthn:creation:failure', verificationJSON.errorMessage); + catch (e) { + this._dispatchEvent('webauthn:attestation:failure', {}); + return; } } _dispatchEvent(name, payload) { this.element.dispatchEvent(new CustomEvent(name, { detail: payload, bubbles: true })); } - fetch(method, url, body) { - return new Promise(function (resolve, reject) { - const xhr = new XMLHttpRequest(); - xhr.open(method, url); - xhr.responseType = 'json'; - xhr.setRequestHeader('Content-Type', 'application/json'); - xhr.onload = function () { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(xhr); - } - else { - reject({ - status: xhr.status, - statusText: xhr.statusText, - }); - } - }; - xhr.onerror = function () { - reject({ - status: xhr.status, - statusText: xhr.statusText, - }); - }; - xhr.send(body); - }); - } _getData() { let data = new FormData(); try { + this.element.reportValidity(); + if (!this.element.checkValidity()) { + return; + } data = new FormData(this.element); } catch (e) { @@ -107,6 +102,51 @@ class default_1 extends Controller { authenticatorAttachment: data.get(this.authenticatorAttachmentFieldValue), }); } + async _getPublicKeyCredentialRequestOptions(formData) { + return this._getOptions(this.requestOptionsUrlValue, formData); + } + async _getPublicKeyCredentialCreationOptions(formData) { + return this._getOptions(this.creationOptionsUrlValue, formData); + } + async _getOptions(url, formData) { + const data = formData || this._getData(); + if (!data) { + return false; + } + this._dispatchEvent('webauthn:options:request', { data }); + const optionsResponse = await fetch(url, { + headers: Object.assign({}, this.requestHeadersValue), + method: 'POST', + body: JSON.stringify(data) + }); + if (!optionsResponse.ok) { + this._dispatchEvent('webauthn:options:failure', {}); + return false; + } + const options = await optionsResponse.json(); + this._dispatchEvent('webauthn:options:success', { data: options }); + return options; + } + async _getAttestationResponse(authenticatorResponse) { + return this._getResult(this.creationResultUrlValue, 'webauthn:attestation:', authenticatorResponse); + } + async _getAssertionResponse(authenticatorResponse) { + return this._getResult(this.requestResultUrlValue, 'webauthn:assertion:', authenticatorResponse); + } + async _getResult(url, eventPrefix, authenticatorResponse) { + const attestationResponse = await fetch(url, { + headers: Object.assign({}, this.requestHeadersValue), + method: 'POST', + body: JSON.stringify(authenticatorResponse) + }); + if (!attestationResponse.ok) { + this._dispatchEvent(eventPrefix + 'failure', {}); + return false; + } + const attestationResponseJSON = await attestationResponse.json(); + this._dispatchEvent(eventPrefix + 'success', { data: attestationResponseJSON }); + return attestationResponseJSON; + } } default_1.values = { requestResultUrl: { type: String, default: '/request' }, @@ -122,6 +162,12 @@ default_1.values = { residentKeyField: { type: String, default: 'residentKey' }, authenticatorAttachmentField: { type: String, default: 'authenticatorAttachment' }, useBrowserAutofill: { type: Boolean, default: false }, + requestHeaders: { type: Object, default: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'mode': 'no-cors', + 'credentials': 'include' + } }, }; export { default_1 as default }; diff --git a/src/stimulus/assets/src/controller.ts b/src/stimulus/assets/src/controller.ts index 662f3788c..cab7e224c 100644 --- a/src/stimulus/assets/src/controller.ts +++ b/src/stimulus/assets/src/controller.ts @@ -1,7 +1,11 @@ 'use strict'; import { Controller } from '@hotwired/stimulus'; -import { startAuthentication, startRegistration } from '@simplewebauthn/browser'; +import { + AuthenticationResponseJSON, + RegistrationResponseJSON +} from '@simplewebauthn/types'; +import { browserSupportsWebAuthn, browserSupportsWebAuthnAutofill, startAuthentication, startRegistration } from '@simplewebauthn/browser'; export default class extends Controller { static values = { @@ -18,6 +22,12 @@ export default class extends Controller { residentKeyField: { type: String, default: 'residentKey' }, authenticatorAttachmentField: { type: String, default: 'authenticatorAttachment' }, useBrowserAutofill: { type: Boolean, default: false }, + requestHeaders: { type: Object, default: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'mode': 'no-cors', + 'credentials': 'include' + } }, }; declare readonly requestResultUrlValue: string; @@ -33,8 +43,9 @@ export default class extends Controller { declare readonly residentKeyFieldValue: string; declare readonly authenticatorAttachmentFieldValue: string; declare readonly useBrowserAutofillValue: boolean; + declare readonly requestHeadersValue: object; - connect() { + public connect = async () => { const options = { requestResultUrl: this.requestResultUrlValue, requestOptionsUrl: this.requestOptionsUrlValue, @@ -45,90 +56,85 @@ export default class extends Controller { }; this._dispatchEvent('webauthn:connect', { options }); + const supportAutofill = await browserSupportsWebAuthnAutofill(); + + if (supportAutofill && this.useBrowserAutofillValue) { + const optionsResponseJson = await this._getPublicKeyCredentialRequestOptions({}); + if (!optionsResponseJson) { + return; + } + this._processSignin(optionsResponseJson, true); + } } - async signin(event: Event): Promise { + public async signin(event: Event): Promise { + if (!browserSupportsWebAuthn()) { + this._dispatchEvent('webauthn:unsupported', {}); + return; + } event.preventDefault(); - const data = this._getData(); - - this._dispatchEvent('webauthn:request:options', { data }); - - const resp = await this.fetch('POST', this.requestOptionsUrlValue, JSON.stringify(data)); - const respJson = await resp.response; - const asseResp = await startAuthentication(respJson, this.useBrowserAutofillValue); + const optionsResponseJson = await this._getPublicKeyCredentialRequestOptions(null); + if (!optionsResponseJson) { + return; + } + this._processSignin(optionsResponseJson, false); + } - const verificationResp = await this.fetch('POST', this.requestResultUrlValue, JSON.stringify(asseResp)); - const verificationJSON = await verificationResp.response; - this._dispatchEvent('webauthn:request:response', { response: asseResp }); + private async _processSignin(optionsResponseJson: Object, useBrowserAutofill: boolean): Promise { + try { + // @ts-ignore + const authenticatorResponse = await startAuthentication(optionsResponseJson, useBrowserAutofill); + this._dispatchEvent('webauthn:authenticator:response', { response: authenticatorResponse }); - if (verificationJSON && verificationJSON.errorMessage === '') { - this._dispatchEvent('webauthn:request:success', verificationJSON); - if (this.requestSuccessRedirectUriValue) { + const assertionResponse = await this._getAssertionResponse(authenticatorResponse); + if (assertionResponse !== false && this.requestSuccessRedirectUriValue) { window.location.replace(this.requestSuccessRedirectUriValue); } - } else { - this._dispatchEvent('webauthn:request:failure', verificationJSON.errorMessage); + } catch (e) { + this._dispatchEvent('webauthn:assertion:failure', {}); + return; } } - async signup(event: Event): Promise { - event.preventDefault(); - const data = this._getData(); - this._dispatchEvent('webauthn:creation:options', { data }); - const resp = await this.fetch('POST', this.creationOptionsUrlValue, JSON.stringify(data)); + public async signup(event: Event): Promise { + try { + if (!browserSupportsWebAuthn()) { + this._dispatchEvent('webauthn:unsupported', {}); + return; + } + event.preventDefault(); + const optionsResponseJson = await this._getPublicKeyCredentialCreationOptions(null); + if (!optionsResponseJson) { + return; + } - const respJson = await resp.response; - if (respJson.excludeCredentials === undefined) { - respJson.excludeCredentials = []; - } - const attResp = await startRegistration(respJson); - this._dispatchEvent('webauthn:creation:response', { response: attResp }); - const verificationResp = await this.fetch('POST', this.creationResultUrlValue, JSON.stringify(attResp)); - - const verificationJSON = await verificationResp.response; - if (verificationJSON && verificationJSON.errorMessage === '') { - this._dispatchEvent('webauthn:creation:success', verificationJSON); - if (this.creationSuccessRedirectUriValue) { + // @ts-ignore + const authenticatorResponse = await startRegistration(optionsResponseJson); + this._dispatchEvent('webauthn:authenticator:response', { response: authenticatorResponse }); + + const attestationResponseJSON = await this._getAttestationResponse(authenticatorResponse); + if (attestationResponseJSON !== false && this.creationSuccessRedirectUriValue) { window.location.replace(this.creationSuccessRedirectUriValue); } - } else { - this._dispatchEvent('webauthn:creation:failure', verificationJSON.errorMessage); + } catch (e) { + this._dispatchEvent('webauthn:attestation:failure', {}); + return; } } - _dispatchEvent(name: string, payload: any): void { + private _dispatchEvent(name: string, payload: any): void { this.element.dispatchEvent(new CustomEvent(name, { detail: payload, bubbles: true })); } - fetch(method: string, url: string, body: string): Promise { - return new Promise(function (resolve, reject) { - const xhr = new XMLHttpRequest(); - xhr.open(method, url); - xhr.responseType = 'json'; - xhr.setRequestHeader('Content-Type', 'application/json'); - xhr.onload = function () { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(xhr); - } else { - reject({ - status: xhr.status, - statusText: xhr.statusText, - }); - } - }; - xhr.onerror = function () { - reject({ - status: xhr.status, - statusText: xhr.statusText, - }); - }; - xhr.send(body); - }); - } - - _getData() { + private _getData() { let data = new FormData(); try { + // @ts-ignore + this.element.reportValidity() + // @ts-ignore + if (!this.element.checkValidity()) { + return; + } // @ts-ignore data = new FormData(this.element); } catch (e) { @@ -150,4 +156,60 @@ export default class extends Controller { authenticatorAttachment: data.get(this.authenticatorAttachmentFieldValue), }); } + + private async _getPublicKeyCredentialRequestOptions(formData: null|Object): Promise { + return this._getOptions(this.requestOptionsUrlValue, formData); + } + + private async _getPublicKeyCredentialCreationOptions(formData: null|Object): Promise { + return this._getOptions(this.creationOptionsUrlValue, formData); + } + + private async _getOptions(url: string, formData: null|Object): Promise { + const data = formData || this._getData(); + if (!data) { + return false; + } + + this._dispatchEvent('webauthn:options:request', { data }); + const optionsResponse = await fetch(url, { + headers: {...this.requestHeadersValue}, + method: 'POST', + body: JSON.stringify(data) + }); + if (!optionsResponse.ok) { + this._dispatchEvent('webauthn:options:failure', {}); + return false; + } + + const options = await optionsResponse.json(); + this._dispatchEvent('webauthn:options:success', {data: options}); + + return options; + } + + private async _getAttestationResponse(authenticatorResponse: RegistrationResponseJSON) { + return this._getResult(this.creationResultUrlValue, 'webauthn:attestation:', authenticatorResponse); + } + + private async _getAssertionResponse(authenticatorResponse: AuthenticationResponseJSON) { + return this._getResult(this.requestResultUrlValue, 'webauthn:assertion:', authenticatorResponse); + } + + private async _getResult(url: string, eventPrefix: string, authenticatorResponse: RegistrationResponseJSON|AuthenticationResponseJSON): Promise { + + const attestationResponse = await fetch(url, { + headers: {...this.requestHeadersValue}, + method:'POST', + body: JSON.stringify(authenticatorResponse) + }); + if (!attestationResponse.ok) { + this._dispatchEvent(eventPrefix+'failure', {}); + return false; + } + const attestationResponseJSON = await attestationResponse.json(); + this._dispatchEvent(eventPrefix+'success', {data:attestationResponseJSON}); + + return attestationResponseJSON; + } }