diff options
author | Nick Thomas <nick@gitlab.com> | 2018-11-12 10:52:48 +0000 |
---|---|---|
committer | Nick Thomas <nick@gitlab.com> | 2018-11-19 11:46:39 +0000 |
commit | f1bc7b6eb5cb9beab55e4edac87cc5e0b7ceb069 (patch) | |
tree | 2e5aedd22e2fd05909c1e97c5bc52602feadc825 /app/assets/javascripts/mirrors | |
parent | b1b4c94484b0613a6a457e32218d4f62b9eb2029 (diff) | |
download | gitlab-ce-f1bc7b6eb5cb9beab55e4edac87cc5e0b7ceb069.tar.gz |
SSH public-key authentication for push mirroring
Diffstat (limited to 'app/assets/javascripts/mirrors')
-rw-r--r-- | app/assets/javascripts/mirrors/constants.js | 4 | ||||
-rw-r--r-- | app/assets/javascripts/mirrors/mirror_repos.js | 108 | ||||
-rw-r--r-- | app/assets/javascripts/mirrors/ssh_mirror.js | 299 |
3 files changed, 411 insertions, 0 deletions
diff --git a/app/assets/javascripts/mirrors/constants.js b/app/assets/javascripts/mirrors/constants.js new file mode 100644 index 00000000000..8dd6a726425 --- /dev/null +++ b/app/assets/javascripts/mirrors/constants.js @@ -0,0 +1,4 @@ +export default { + PASSWORD: 'password', + SSH: 'ssh_public_key', +}; diff --git a/app/assets/javascripts/mirrors/mirror_repos.js b/app/assets/javascripts/mirrors/mirror_repos.js new file mode 100644 index 00000000000..0d8f31d6bfc --- /dev/null +++ b/app/assets/javascripts/mirrors/mirror_repos.js @@ -0,0 +1,108 @@ +import $ from 'jquery'; +import _ from 'underscore'; +import { __ } from '~/locale'; +import Flash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import SSHMirror from './ssh_mirror'; + +export default class MirrorRepos { + constructor(container) { + this.$container = $(container); + this.$password = null; + this.$form = $('.js-mirror-form', this.$container); + this.$urlInput = $('.js-mirror-url', this.$form); + this.$protectedBranchesInput = $('.js-mirror-protected', this.$form); + this.$table = $('.js-mirrors-table-body', this.$container); + this.mirrorEndpoint = this.$form.data('projectMirrorEndpoint'); + } + + init() { + this.initMirrorPush(); + this.registerUpdateListeners(); + } + + initMirrorPush() { + this.$passwordGroup = $('.js-password-group', this.$container); + this.$password = $('.js-password', this.$passwordGroup); + this.$authMethod = $('.js-auth-method', this.$form); + + this.$authMethod.on('change', () => this.togglePassword()); + this.$password.on('input.updateUrl', () => this.debouncedUpdateUrl()); + + this.initMirrorSSH(); + } + + initMirrorSSH() { + if (this.$password) { + this.$password.off('input.updateUrl'); + } + this.$password = undefined; + + this.sshMirror = new SSHMirror('.js-mirror-form'); + this.sshMirror.init(); + } + + updateUrl() { + let val = this.$urlInput.val(); + + if (this.$password) { + const password = this.$password.val(); + if (password) val = val.replace('@', `:${password}@`); + } + + $('.js-mirror-url-hidden', this.$form).val(val); + } + + updateProtectedBranches() { + const val = this.$protectedBranchesInput.get(0).checked + ? this.$protectedBranchesInput.val() + : '0'; + $('.js-mirror-protected-hidden', this.$form).val(val); + } + + registerUpdateListeners() { + this.debouncedUpdateUrl = _.debounce(() => this.updateUrl(), 200); + this.$urlInput.on('input', () => this.debouncedUpdateUrl()); + this.$protectedBranchesInput.on('change', () => this.updateProtectedBranches()); + this.$table.on('click', '.js-delete-mirror', event => this.deleteMirror(event)); + } + + togglePassword() { + const isPassword = this.$authMethod.val() === 'password'; + + if (!isPassword) { + this.$password.val(''); + this.updateUrl(); + } + this.$passwordGroup.collapse(isPassword ? 'show' : 'hide'); + } + + deleteMirror(event, existingPayload) { + const $target = $(event.currentTarget); + let payload = existingPayload; + + if (!payload) { + payload = { + project: { + remote_mirrors_attributes: { + id: $target.data('mirrorId'), + enabled: 0, + }, + }, + }; + } + + return axios + .put(this.mirrorEndpoint, payload) + .then(() => this.removeRow($target)) + .catch(() => Flash(__('Failed to remove mirror.'))); + } + + /* eslint-disable class-methods-use-this */ + removeRow($target) { + const row = $target.closest('tr'); + $('.js-delete-mirror', row).tooltip('hide'); + row.remove(); + } + /* eslint-enable class-methods-use-this */ +} diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js new file mode 100644 index 00000000000..5bdf5d6277a --- /dev/null +++ b/app/assets/javascripts/mirrors/ssh_mirror.js @@ -0,0 +1,299 @@ +import $ from 'jquery'; +import _ from 'underscore'; +import { __ } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; +import Flash from '~/flash'; +import { backOff } from '~/lib/utils/common_utils'; +import AUTH_METHOD from './constants'; + +export default class SSHMirror { + constructor(formSelector) { + this.backOffRequestCounter = 0; + + this.$form = $(formSelector); + + this.$repositoryUrl = this.$form.find('.js-repo-url'); + this.$knownHosts = this.$form.find('.js-known-hosts'); + + this.$sectionSSHHostKeys = this.$form.find('.js-ssh-host-keys-section'); + this.$hostKeysInformation = this.$form.find('.js-fingerprint-ssh-info'); + this.$btnDetectHostKeys = this.$form.find('.js-detect-host-keys'); + this.$btnSSHHostsShowAdvanced = this.$form.find('.btn-show-advanced'); + this.$dropdownAuthType = this.$form.find('.js-mirror-auth-type'); + + this.$wellAuthTypeChanging = this.$form.find('.js-well-changing-auth'); + this.$wellPasswordAuth = this.$form.find('.js-well-password-auth'); + this.$wellSSHAuth = this.$form.find('.js-well-ssh-auth'); + this.$sshPublicKeyWrap = this.$form.find('.js-ssh-public-key-wrap'); + this.$regeneratePublicSshKeyButton = this.$wellSSHAuth.find('.js-btn-regenerate-ssh-key'); + this.$regeneratePublicSshKeyModal = this.$wellSSHAuth.find( + '.js-regenerate-public-ssh-key-confirm-modal', + ); + } + + init() { + this.handleRepositoryUrlInput(true); + + this.$repositoryUrl.on('keyup', () => this.handleRepositoryUrlInput()); + this.$knownHosts.on('keyup', e => this.handleSSHKnownHostsInput(e)); + this.$dropdownAuthType.on('change', e => this.handleAuthTypeChange(e)); + this.$btnDetectHostKeys.on('click', e => this.handleDetectHostKeys(e)); + this.$btnSSHHostsShowAdvanced.on('click', e => this.handleSSHHostsAdvanced(e)); + this.$regeneratePublicSshKeyButton.on('click', () => + this.$regeneratePublicSshKeyModal.toggle(true), + ); + $('.js-confirm', this.$regeneratePublicSshKeyModal).on('click', e => + this.regeneratePublicSshKey(e), + ); + $('.js-cancel', this.$regeneratePublicSshKeyModal).on('click', () => + this.$regeneratePublicSshKeyModal.toggle(false), + ); + } + + /** + * Method to monitor Git Repository URL input + */ + handleRepositoryUrlInput(forceMatch) { + const protocol = this.$repositoryUrl.val().split('://')[0]; + const protRegEx = /http|git/; + + // Validate URL and verify if it consists only supported protocols + if (forceMatch || this.$form.get(0).checkValidity()) { + const isSsh = protocol === 'ssh'; + // Hide/Show SSH Host keys section only for SSH URLs + this.$sectionSSHHostKeys.collapse(isSsh ? 'show' : 'hide'); + this.$btnDetectHostKeys.enable(); + + // Verify if URL is http, https or git and hide/show Auth type dropdown + // as we don't support auth type SSH for non-SSH URLs + const matchesProtocol = protRegEx.test(protocol); + this.$dropdownAuthType.attr('disabled', matchesProtocol); + + if (forceMatch && isSsh) { + this.$dropdownAuthType.val(AUTH_METHOD.SSH); + this.toggleAuthWell(AUTH_METHOD.SSH); + } else { + this.$dropdownAuthType.val(AUTH_METHOD.PASSWORD); + this.toggleAuthWell(AUTH_METHOD.PASSWORD); + } + } + } + + /** + * Click event handler to detect SSH Host key and fingerprints from + * provided Git Repository URL. + */ + handleDetectHostKeys() { + const projectMirrorSSHEndpoint = this.$form.data('project-mirror-ssh-endpoint'); + const repositoryUrl = this.$repositoryUrl.val(); + const currentKnownHosts = this.$knownHosts.val(); + const $btnLoadSpinner = this.$btnDetectHostKeys.find('.js-spinner'); + + // Disable button while we make request + this.$btnDetectHostKeys.disable(); + $btnLoadSpinner.removeClass('d-none'); + + // Make backOff polling to get data + backOff((next, stop) => { + axios + .get( + `${projectMirrorSSHEndpoint}?ssh_url=${repositoryUrl}&compare_host_keys=${encodeURIComponent( + currentKnownHosts, + )}`, + ) + .then(({ data, status }) => { + if (status === 204) { + this.backOffRequestCounter += 1; + if (this.backOffRequestCounter < 3) { + next(); + } else { + stop(data); + } + } else { + stop(data); + } + }) + .catch(stop); + }) + .then(res => { + $btnLoadSpinner.addClass('d-none'); + // Once data is received, we show verification info along with Host keys and fingerprints + this.$hostKeysInformation + .find('.js-fingerprint-verification') + .collapse(res.host_keys_changed ? 'hide' : 'show'); + if (res.known_hosts && res.fingerprints) { + this.showSSHInformation(res); + } + }) + .catch(({ response }) => { + // Show failure message when there's an error and re-enable Detect host keys button + const failureMessage = response.data + ? response.data.message + : __('An error occurred while detecting host keys'); + Flash(failureMessage); + + $btnLoadSpinner.addClass('hidden'); + this.$btnDetectHostKeys.enable(); + }); + } + + /** + * Method to monitor known hosts textarea input + */ + handleSSHKnownHostsInput() { + // Strike-out fingerprints and remove verification info if `known hosts` value is altered + this.$hostKeysInformation.find('.js-fingerprints-list').addClass('invalidate'); + this.$hostKeysInformation.find('.js-fingerprint-verification').collapse('hide'); + } + + /** + * Click event handler for `Show advanced` button under SSH Host keys section + */ + handleSSHHostsAdvanced() { + const $knownHost = this.$sectionSSHHostKeys.find('.js-ssh-known-hosts'); + const toggleShowAdvanced = $knownHost.hasClass('show'); + + $knownHost.collapse('toggle'); + this.$btnSSHHostsShowAdvanced.toggleClass('show-advanced', toggleShowAdvanced); + } + + /** + * Authentication method dropdown change event listener + */ + handleAuthTypeChange() { + const projectMirrorAuthTypeEndpoint = `${this.$form.attr('action')}.json`; + const $sshPublicKey = this.$sshPublicKeyWrap.find('.ssh-public-key'); + const selectedAuthType = this.$dropdownAuthType.val(); + + this.$wellPasswordAuth.collapse('hide'); + this.$wellSSHAuth.collapse('hide'); + + // This request should happen only if selected Auth type was SSH + // and SSH Public key was not present on page load + if (selectedAuthType === AUTH_METHOD.SSH && !$sshPublicKey.text().trim()) { + if (!this.$wellSSHAuth.length) return; + + // Construct request body + const authTypeData = { + project: { + ...this.$regeneratePublicSshKeyButton.data().projectData, + }, + }; + + this.$wellAuthTypeChanging.collapse('show'); + this.$dropdownAuthType.disable(); + + axios + .put(projectMirrorAuthTypeEndpoint, JSON.stringify(authTypeData), { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + }) + .then(({ data }) => { + // Show SSH public key container and fill in public key + this.toggleAuthWell(selectedAuthType); + this.toggleSSHAuthWellMessage(true); + this.setSSHPublicKey(data.import_data_attributes.ssh_public_key); + + this.$wellAuthTypeChanging.collapse('hide'); + this.$dropdownAuthType.enable(); + }) + .catch(() => { + Flash(__('Something went wrong on our end.')); + + this.$wellAuthTypeChanging.collapse('hide'); + this.$dropdownAuthType.enable(); + }); + } else { + this.toggleAuthWell(selectedAuthType); + this.$wellSSHAuth.find('.js-ssh-public-key-present').collapse('show'); + } + } + + /** + * Method to parse SSH Host keys data and render it + * under SSH host keys section + */ + showSSHInformation(sshHostKeys) { + const $fingerprintsList = this.$hostKeysInformation.find('.js-fingerprints-list'); + let fingerprints = ''; + sshHostKeys.fingerprints.forEach(fingerprint => { + const escFingerprints = _.escape(fingerprint.fingerprint); + fingerprints += `<code>${escFingerprints}</code>`; + }); + + this.$hostKeysInformation.collapse('show'); + $fingerprintsList.removeClass('invalidate'); + $fingerprintsList.html(fingerprints); + this.$sectionSSHHostKeys.find('.js-known-hosts').val(sshHostKeys.known_hosts); + } + + /** + * Toggle Auth type information container based on provided `authType` + */ + toggleAuthWell(authType) { + this.$wellPasswordAuth.collapse(authType === AUTH_METHOD.PASSWORD ? 'show' : 'hide'); + this.$wellSSHAuth.collapse(authType === AUTH_METHOD.SSH ? 'show' : 'hide'); + } + + /** + * Toggle SSH auth information message + */ + toggleSSHAuthWellMessage(sshKeyPresent) { + this.$sshPublicKeyWrap.collapse(sshKeyPresent ? 'show' : 'hide'); + this.$wellSSHAuth.find('.js-ssh-public-key-present').collapse(sshKeyPresent ? 'show' : 'hide'); + this.$regeneratePublicSshKeyButton.collapse(sshKeyPresent ? 'show' : 'hide'); + this.$wellSSHAuth.find('.js-ssh-public-key-pending').collapse(sshKeyPresent ? 'hide' : 'show'); + } + + /** + * Sets SSH Public key to Clipboard button and shows it on UI. + */ + setSSHPublicKey(sshPublicKey) { + this.$sshPublicKeyWrap.find('.ssh-public-key').text(sshPublicKey); + this.$sshPublicKeyWrap + .find('.btn-copy-ssh-public-key') + .attr('data-clipboard-text', sshPublicKey); + } + + regeneratePublicSshKey(event) { + event.preventDefault(); + + this.$regeneratePublicSshKeyModal.toggle(false); + + const button = this.$regeneratePublicSshKeyButton; + const spinner = $('.js-spinner', button); + const endpoint = button.data('endpoint'); + const authTypeData = { + project: { + ...this.$regeneratePublicSshKeyButton.data().projectData, + }, + }; + + button.attr('disabled', 'disabled'); + spinner.removeClass('d-none'); + + axios + .patch(endpoint, authTypeData) + .then(({ data }) => { + button.removeAttr('disabled'); + spinner.addClass('d-none'); + + this.setSSHPublicKey(data.import_data_attributes.ssh_public_key); + }) + .catch(() => { + Flash(_('Unable to regenerate public ssh key.')); + }); + } + + destroy() { + this.$repositoryUrl.off('keyup'); + this.$form.find('.js-known-hosts').off('keyup'); + this.$dropdownAuthType.off('change'); + this.$btnDetectHostKeys.off('click'); + this.$btnSSHHostsShowAdvanced.off('click'); + this.$regeneratePublicSshKeyButton.off('click'); + $('.js-confirm', this.$regeneratePublicSshKeyModal).off('click'); + $('.js-cancel', this.$regeneratePublicSshKeyModal).off('click'); + } +} |