diff options
Diffstat (limited to 'app/assets/javascripts/authentication/webauthn')
6 files changed, 332 insertions, 0 deletions
diff --git a/app/assets/javascripts/authentication/webauthn/authenticate.js b/app/assets/javascripts/authentication/webauthn/authenticate.js new file mode 100644 index 00000000000..42c4c2b63bd --- /dev/null +++ b/app/assets/javascripts/authentication/webauthn/authenticate.js @@ -0,0 +1,69 @@ +import WebAuthnError from './error'; +import WebAuthnFlow from './flow'; +import { supported, convertGetParams, convertGetResponse } from './util'; + +// Authenticate WebAuthn devices for users to authenticate with. +// +// State Flow #1: setup -> in_progress -> authenticated -> POST to server +// State Flow #2: setup -> in_progress -> error -> setup +export default class WebAuthnAuthenticate { + constructor(container, form, webauthnParams, fallbackButton, fallbackUI) { + this.container = container; + this.webauthnParams = convertGetParams(JSON.parse(webauthnParams.options)); + this.renderInProgress = this.renderInProgress.bind(this); + + this.form = form; + this.fallbackButton = fallbackButton; + this.fallbackUI = fallbackUI; + if (this.fallbackButton) { + this.fallbackButton.addEventListener('click', this.switchToFallbackUI.bind(this)); + } + + this.flow = new WebAuthnFlow(container, { + inProgress: '#js-authenticate-token-2fa-in-progress', + error: '#js-authenticate-token-2fa-error', + authenticated: '#js-authenticate-token-2fa-authenticated', + }); + + this.container.on('click', '#js-token-2fa-try-again', this.renderInProgress); + } + + start() { + if (!supported()) { + this.switchToFallbackUI(); + } else { + this.renderInProgress(); + } + } + + authenticate() { + navigator.credentials + .get({ publicKey: this.webauthnParams }) + .then(resp => { + const convertedResponse = convertGetResponse(resp); + this.renderAuthenticated(JSON.stringify(convertedResponse)); + }) + .catch(err => { + this.flow.renderError(new WebAuthnError(err, 'authenticate')); + }); + } + + renderInProgress() { + this.flow.renderTemplate('inProgress'); + this.authenticate(); + } + + renderAuthenticated(deviceResponse) { + this.flow.renderTemplate('authenticated'); + const container = this.container[0]; + container.querySelector('#js-device-response').value = deviceResponse; + container.querySelector(this.form).submit(); + this.fallbackButton.classList.add('hidden'); + } + + switchToFallbackUI() { + this.fallbackButton.classList.add('hidden'); + this.container[0].classList.add('hidden'); + this.fallbackUI.classList.remove('hidden'); + } +} diff --git a/app/assets/javascripts/authentication/webauthn/error.js b/app/assets/javascripts/authentication/webauthn/error.js new file mode 100644 index 00000000000..a1a3f861c25 --- /dev/null +++ b/app/assets/javascripts/authentication/webauthn/error.js @@ -0,0 +1,28 @@ +import { __ } from '~/locale'; +import { isHTTPS, FLOW_AUTHENTICATE, FLOW_REGISTER } from './util'; + +export default class WebAuthnError { + constructor(error, flowType) { + this.error = error; + this.errorName = error.name || 'UnknownError'; + this.message = this.message.bind(this); + this.httpsDisabled = !isHTTPS(); + this.flowType = flowType; + } + + message() { + if (this.errorName === 'NotSupportedError') { + return __('Your device is not compatible with GitLab. Please try another device'); + } else if (this.errorName === 'InvalidStateError' && this.flowType === FLOW_AUTHENTICATE) { + return __('This device has not been registered with us.'); + } else if (this.errorName === 'InvalidStateError' && this.flowType === FLOW_REGISTER) { + return __('This device has already been registered with us.'); + } else if (this.errorName === 'SecurityError' && this.httpsDisabled) { + return __( + 'WebAuthn only works with HTTPS-enabled websites. Contact your administrator for more details.', + ); + } + + return __('There was a problem communicating with your device.'); + } +} diff --git a/app/assets/javascripts/authentication/webauthn/flow.js b/app/assets/javascripts/authentication/webauthn/flow.js new file mode 100644 index 00000000000..10a1debc876 --- /dev/null +++ b/app/assets/javascripts/authentication/webauthn/flow.js @@ -0,0 +1,24 @@ +import { template } from 'lodash'; + +/** + * Generic abstraction for WebAuthnFlows, especially for register / authenticate + */ +export default class WebAuthnFlow { + constructor(container, templates) { + this.container = container; + this.templates = templates; + } + + renderTemplate(name, params) { + const templateString = document.querySelector(this.templates[name]).innerHTML; + const compiledTemplate = template(templateString); + this.container.html(compiledTemplate(params)); + } + + renderError(error) { + this.renderTemplate('error', { + error_message: error.message(), + error_name: error.errorName, + }); + } +} diff --git a/app/assets/javascripts/authentication/webauthn/index.js b/app/assets/javascripts/authentication/webauthn/index.js new file mode 100644 index 00000000000..bbf694c7698 --- /dev/null +++ b/app/assets/javascripts/authentication/webauthn/index.js @@ -0,0 +1,13 @@ +import $ from 'jquery'; +import WebAuthnAuthenticate from './authenticate'; + +export default () => { + const webauthnAuthenticate = new WebAuthnAuthenticate( + $('#js-authenticate-token-2fa'), + '#js-login-token-2fa-form', + gon.webauthn, + document.querySelector('#js-login-2fa-device'), + document.querySelector('.js-2fa-form'), + ); + webauthnAuthenticate.start(); +}; diff --git a/app/assets/javascripts/authentication/webauthn/register.js b/app/assets/javascripts/authentication/webauthn/register.js new file mode 100644 index 00000000000..06e4ffd6f3e --- /dev/null +++ b/app/assets/javascripts/authentication/webauthn/register.js @@ -0,0 +1,78 @@ +import { __ } from '~/locale'; +import WebAuthnError from './error'; +import WebAuthnFlow from './flow'; +import { supported, isHTTPS, convertCreateParams, convertCreateResponse } from './util'; + +// Register WebAuthn devices for users to authenticate with. +// +// State Flow #1: setup -> in_progress -> registered -> POST to server +// State Flow #2: setup -> in_progress -> error -> setup +export default class WebAuthnRegister { + constructor(container, webauthnParams) { + this.container = container; + this.renderInProgress = this.renderInProgress.bind(this); + this.webauthnOptions = convertCreateParams(webauthnParams.options); + + this.flow = new WebAuthnFlow(container, { + message: '#js-register-2fa-message', + setup: '#js-register-token-2fa-setup', + error: '#js-register-token-2fa-error', + registered: '#js-register-token-2fa-registered', + }); + + this.container.on('click', '#js-token-2fa-try-again', this.renderInProgress); + } + + start() { + if (!supported()) { + // we show a special error message when the user visits the site + // using a non-ssl connection as this makes WebAuthn unavailable in + // any case, regardless of the used browser + this.renderNotSupported(!isHTTPS()); + } else { + this.renderSetup(); + } + } + + register() { + navigator.credentials + .create({ + publicKey: this.webauthnOptions, + }) + .then(cred => this.renderRegistered(JSON.stringify(convertCreateResponse(cred)))) + .catch(err => this.flow.renderError(new WebAuthnError(err, 'register'))); + } + + renderSetup() { + this.flow.renderTemplate('setup'); + this.container.find('#js-setup-token-2fa-device').on('click', this.renderInProgress); + } + + renderInProgress() { + this.flow.renderTemplate('message', { + message: __( + 'Trying to communicate with your device. Plug it in (if needed) and press the button on the device now.', + ), + }); + return this.register(); + } + + renderRegistered(deviceResponse) { + this.flow.renderTemplate('registered'); + // Prefer to do this instead of interpolating using Underscore templates + // because of JSON escaping issues. + this.container.find('#js-device-response').val(deviceResponse); + } + + renderNotSupported(noHttps) { + const message = noHttps + ? __( + 'WebAuthn only works with HTTPS-enabled websites. Contact your administrator for more details.', + ) + : __( + "Your browser doesn't support WebAuthn. Please use a supported browser, e.g. Chrome (67+) or Firefox (60+).", + ); + + this.flow.renderTemplate('message', { message }); + } +} diff --git a/app/assets/javascripts/authentication/webauthn/util.js b/app/assets/javascripts/authentication/webauthn/util.js new file mode 100644 index 00000000000..5f06c000afe --- /dev/null +++ b/app/assets/javascripts/authentication/webauthn/util.js @@ -0,0 +1,120 @@ +export function supported() { + return Boolean( + navigator.credentials && + navigator.credentials.create && + navigator.credentials.get && + window.PublicKeyCredential, + ); +} + +export function isHTTPS() { + return window.location.protocol.startsWith('https'); +} + +export const FLOW_AUTHENTICATE = 'authenticate'; +export const FLOW_REGISTER = 'register'; + +// adapted from https://stackoverflow.com/a/21797381/8204697 +function base64ToBuffer(base64) { + const binaryString = window.atob(base64); + const len = binaryString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i += 1) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes.buffer; +} + +// adapted from https://stackoverflow.com/a/9458996/8204697 +function bufferToBase64(buffer) { + if (typeof buffer === 'string') { + return buffer; + } + + let binary = ''; + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + for (let i = 0; i < len; i += 1) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); +} + +/** + * Returns a copy of the given object with the id property converted to buffer + * + * @param {Object} param + */ +function convertIdToBuffer({ id, ...rest }) { + return { + ...rest, + id: base64ToBuffer(id), + }; +} + +/** + * Returns a copy of the given array with all `id`s of the items converted to buffer + * + * @param {Array} items + */ +function convertIdsToBuffer(items) { + return items.map(convertIdToBuffer); +} + +/** + * Returns an object with keys of the given props, and values from the given object converted to base64 + * + * @param {String} obj + * @param {Array} props + */ +function convertPropertiesToBase64(obj, props) { + return props.reduce( + (acc, property) => Object.assign(acc, { [property]: bufferToBase64(obj[property]) }), + {}, + ); +} + +export function convertGetParams({ allowCredentials, challenge, ...rest }) { + return { + ...rest, + ...(allowCredentials ? { allowCredentials: convertIdsToBuffer(allowCredentials) } : {}), + challenge: base64ToBuffer(challenge), + }; +} + +export function convertGetResponse(webauthnResponse) { + return { + type: webauthnResponse.type, + id: webauthnResponse.id, + rawId: bufferToBase64(webauthnResponse.rawId), + response: convertPropertiesToBase64(webauthnResponse.response, [ + 'clientDataJSON', + 'authenticatorData', + 'signature', + 'userHandle', + ]), + clientExtensionResults: webauthnResponse.getClientExtensionResults(), + }; +} + +export function convertCreateParams({ challenge, user, excludeCredentials, ...rest }) { + return { + ...rest, + challenge: base64ToBuffer(challenge), + user: convertIdToBuffer(user), + ...(excludeCredentials ? { excludeCredentials: convertIdsToBuffer(excludeCredentials) } : {}), + }; +} + +export function convertCreateResponse(webauthnResponse) { + return { + type: webauthnResponse.type, + id: webauthnResponse.id, + rawId: bufferToBase64(webauthnResponse.rawId), + clientExtensionResults: webauthnResponse.getClientExtensionResults(), + response: convertPropertiesToBase64(webauthnResponse.response, [ + 'clientDataJSON', + 'attestationObject', + ]), + }; +} |