diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-09-07 15:08:27 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-09-07 15:08:27 +0300 |
commit | a8653790086d284cecffdc35892bb422cd6c9a7b (patch) | |
tree | 8d1f4dc69026a42a47b1026eef2566c7461a52fe /app/assets/javascripts/authentication | |
parent | 444f662b8d8cbe47a8f3fa1db6ed926d64f3def3 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/authentication')
9 files changed, 364 insertions, 16 deletions
diff --git a/app/assets/javascripts/authentication/mount_2fa.js b/app/assets/javascripts/authentication/mount_2fa.js index 9917151ac81..dd5a42fa5fc 100644 --- a/app/assets/javascripts/authentication/mount_2fa.js +++ b/app/assets/javascripts/authentication/mount_2fa.js @@ -1,14 +1,23 @@ import $ from 'jquery'; import initU2F from './u2f'; +import initWebauthn from './webauthn'; import U2FRegister from './u2f/register'; +import WebAuthnRegister from './webauthn/register'; export const mount2faAuthentication = () => { - // Soon this will conditionally mount a webauthn app (see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/26692) - initU2F(); + if (gon.webauthn) { + initWebauthn(); + } else { + initU2F(); + } }; export const mount2faRegistration = () => { - // Soon this will conditionally mount a webauthn app (see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/26692) - const u2fRegister = new U2FRegister($('#js-register-u2f'), gon.u2f); - u2fRegister.start(); + if (gon.webauthn) { + const webauthnRegister = new WebAuthnRegister($('#js-register-token-2fa'), gon.webauthn); + webauthnRegister.start(); + } else { + const u2fRegister = new U2FRegister($('#js-register-token-2fa'), gon.u2f); + u2fRegister.start(); + } }; diff --git a/app/assets/javascripts/authentication/u2f/authenticate.js b/app/assets/javascripts/authentication/u2f/authenticate.js index 201cd5c2e61..f9b5ca3e5b4 100644 --- a/app/assets/javascripts/authentication/u2f/authenticate.js +++ b/app/assets/javascripts/authentication/u2f/authenticate.js @@ -40,7 +40,6 @@ export default class U2FAuthenticate { this.signRequests = u2fParams.sign_requests.map(request => omit(request, 'challenge')); this.templates = { - setup: '#js-authenticate-token-2fa-setup', inProgress: '#js-authenticate-token-2fa-in-progress', error: '#js-authenticate-token-2fa-error', authenticated: '#js-authenticate-token-2fa-authenticated', @@ -86,7 +85,7 @@ export default class U2FAuthenticate { renderError(error) { this.renderTemplate('error', { error_message: error.message(), - error_code: error.errorCode, + error_name: error.errorCode, }); return this.container.find('#js-token-2fa-try-again').on('click', this.renderInProgress); } diff --git a/app/assets/javascripts/authentication/u2f/register.js b/app/assets/javascripts/authentication/u2f/register.js index 52c0ce1fc04..9773a9185f8 100644 --- a/app/assets/javascripts/authentication/u2f/register.js +++ b/app/assets/javascripts/authentication/u2f/register.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import { template as lodashTemplate } from 'lodash'; +import { __ } from '~/locale'; import importU2FLibrary from './util'; import U2FError from './error'; @@ -24,11 +25,10 @@ export default class U2FRegister { this.signRequests = u2fParams.sign_requests; this.templates = { - notSupported: '#js-register-u2f-not-supported', - setup: '#js-register-u2f-setup', - inProgress: '#js-register-u2f-in-progress', - error: '#js-register-u2f-error', - registered: '#js-register-u2f-registered', + message: '#js-register-2fa-message', + setup: '#js-register-token-2fa-setup', + error: '#js-register-token-2fa-error', + registered: '#js-register-token-2fa-registered', }; } @@ -65,18 +65,22 @@ export default class U2FRegister { renderSetup() { this.renderTemplate('setup'); - return this.container.find('#js-setup-u2f-device').on('click', this.renderInProgress); + return this.container.find('#js-setup-token-2fa-device').on('click', this.renderInProgress); } renderInProgress() { - this.renderTemplate('inProgress'); + this.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(); } renderError(error) { this.renderTemplate('error', { error_message: error.message(), - error_code: error.errorCode, + error_name: error.errorCode, }); return this.container.find('#js-token-2fa-try-again').on('click', this.renderSetup); } @@ -89,6 +93,10 @@ export default class U2FRegister { } renderNotSupported() { - return this.renderTemplate('notSupported'); + return this.renderTemplate('message', { + message: __( + "Your browser doesn't support U2F. Please use Google Chrome desktop (version 41 or newer).", + ), + }); } } 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', + ]), + }; +} |