Commit a8653790 authored by 🤖 GitLab Bot 🤖's avatar 🤖 GitLab Bot 🤖
Browse files

Add latest changes from gitlab-org/gitlab@master

parent 444f662b
......@@ -45,6 +45,7 @@ docs lint:
image: "registry.gitlab.com/gitlab-org/gitlab-docs/lint:vale-2.3.3-markdownlint-0.23.2"
stage: test
needs: []
allow_failure: true
script:
- scripts/lint-doc.sh
# Prepare docs for build
......
......@@ -63,6 +63,8 @@ linters:
- "app/views/admin/users/new.html.haml"
- "app/views/admin/users/projects.html.haml"
- "app/views/admin/users/show.html.haml"
- 'app/views/authentication/_authenticate.html.haml'
- 'app/views/authentication/_register.html.haml'
- "app/views/clusters/clusters/_cluster.html.haml"
- "app/views/clusters/clusters/new.html.haml"
- "app/views/dashboard/milestones/index.html.haml"
......@@ -311,8 +313,6 @@ linters:
- "app/views/shared/web_hooks/_form.html.haml"
- "app/views/shared/web_hooks/_hook.html.haml"
- "app/views/shared/wikis/_pages_wiki_page.html.haml"
- "app/views/u2f/_authenticate.html.haml"
- "app/views/u2f/_register.html.haml"
- "app/views/users/_deletion_guidance.html.haml"
- "ee/app/views/admin/_namespace_plan_info.html.haml"
- "ee/app/views/admin/application_settings/_templates.html.haml"
......
......@@ -512,3 +512,5 @@ gem 'json_schemer', '~> 0.2.12'
gem 'oj', '~> 3.10.6'
gem 'multi_json', '~> 1.14.1'
gem 'yajl-ruby', '~> 1.4.1', require: 'yajl'
gem 'webauthn', '~> 2.3'
......@@ -73,6 +73,7 @@ GEM
public_suffix (>= 2.0.2, < 5.0)
aes_key_wrap (1.0.1)
akismet (3.0.0)
android_key_attestation (0.3.0)
apollo_upload_server (2.0.2)
graphql (>= 1.8)
rails (>= 4.2)
......@@ -93,6 +94,7 @@ GEM
encryptor (~> 3.0.0)
attr_required (1.0.1)
awesome_print (1.8.0)
awrence (1.1.1)
aws-eventstream (1.1.0)
aws-partitions (1.345.0)
aws-sdk-cloudformation (1.41.0)
......@@ -167,6 +169,7 @@ GEM
activemodel (>= 4.0.0)
activesupport (>= 4.0.0)
mime-types (>= 1.16)
cbor (0.5.9.6)
character_set (1.4.0)
charlock_holmes (0.7.6)
childprocess (3.0.0)
......@@ -189,6 +192,9 @@ GEM
contracts (0.11.0)
cork (0.3.0)
colored2 (~> 3.1)
cose (1.0.0)
cbor (~> 0.5.9)
openssl-signature_algorithm (~> 0.4.0)
countries (3.0.0)
i18n_data (~> 0.8.0)
sixarm_ruby_unaccent (~> 1.1)
......@@ -802,6 +808,8 @@ GEM
validate_email
validate_url
webfinger (>= 1.0.1)
openssl (2.2.0)
openssl-signature_algorithm (0.4.0)
opentracing (0.5.0)
optimist (3.0.1)
org-ruby (0.9.12)
......@@ -1026,6 +1034,8 @@ GEM
rubyzip (2.0.0)
rugged (0.28.4.1)
safe_yaml (1.0.4)
safety_net_attestation (0.4.0)
jwt (~> 2.0)
sanitize (5.2.1)
crass (~> 1.0.2)
nokogiri (>= 1.8.0)
......@@ -1050,6 +1060,7 @@ GEM
scss_lint (0.56.0)
rake (>= 0.9, < 13)
sass (~> 3.5.3)
securecompare (1.0.0)
seed-fu (2.3.7)
activerecord (>= 3.1)
activesupport (>= 3.1)
......@@ -1135,6 +1146,9 @@ GEM
parslet (~> 1.8.0)
toml-rb (1.0.0)
citrus (~> 3.0, > 3.0)
tpm-key_attestation (0.9.0)
bindata (~> 2.4)
openssl-signature_algorithm (~> 0.4.0)
truncato (0.7.11)
htmlentities (~> 4.3.1)
nokogiri (>= 1.7.0, <= 2.0)
......@@ -1186,6 +1200,16 @@ GEM
vmstat (2.3.0)
warden (1.2.8)
rack (>= 2.0.6)
webauthn (2.3.0)
android_key_attestation (~> 0.3.0)
awrence (~> 1.1)
bindata (~> 2.4)
cbor (~> 0.5.9)
cose (~> 1.0)
openssl (~> 2.0)
safety_net_attestation (~> 0.4.0)
securecompare (~> 1.0)
tpm-key_attestation (~> 0.9.0)
webfinger (1.1.0)
activesupport
httpclient (>= 2.4)
......@@ -1472,6 +1496,7 @@ DEPENDENCIES
validates_hostname (~> 1.0.10)
version_sorter (~> 2.2.4)
vmstat (~> 2.3.0)
webauthn (~> 2.3)
webmock (~> 3.5.1)
webpack-rails (~> 0.9.10)
wikicloth (= 0.8.1)
......
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();
}
};
......@@ -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);
}
......
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).",
),
});
}
}
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');
}
}
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.');
}
}
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,
});
}
}
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();
};
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 });
}
}
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: weba