Commit f1bc7b6e authored by Nick Thomas's avatar Nick Thomas

SSH public-key authentication for push mirroring

parent b1b4c944
export default {
PASSWORD: 'password',
SSH: 'ssh_public_key',
};
......@@ -3,10 +3,12 @@ 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);
......@@ -26,6 +28,18 @@ export default class MirrorRepos {
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() {
......
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');
}
}
import initForm from '../form';
import MirrorRepos from './mirror_repos';
import MirrorRepos from '~/mirrors/mirror_repos';
document.addEventListener('DOMContentLoaded', () => {
initForm();
......
......@@ -1223,3 +1223,27 @@ pre.light-well {
opacity: 1;
}
}
.project-mirror-settings {
.btn-show-advanced {
min-width: 135px;
.label-show {
display: none;
}
.label-hide {
display: inline;
}
&.show-advanced {
.label-show {
display: inline;
}
.label-hide {
display: none;
}
}
}
}
......@@ -77,6 +77,10 @@ class Projects::MirrorsController < Projects::ApplicationController
id
enabled
only_protected_branches
auth_method
password
ssh_known_hosts
regenerate_ssh_private_key
]
]
end
......
......@@ -2,6 +2,9 @@
module MirrorHelper
def mirrors_form_data_attributes
{ project_mirror_endpoint: project_mirror_path(@project) }
{
project_mirror_ssh_endpoint: ssh_host_keys_project_mirror_path(@project, :json),
project_mirror_endpoint: project_mirror_path(@project, :json)
}
end
end
# frozen_string_literal: true
# Mirroring may use password or SSH public-key authentication. This concern
# implements support for persisting the necessary data in a `credentials`
# serialized attribute. It also needs an `url` method to be defined
module MirrorAuthentication
SSH_PRIVATE_KEY_OPTS = {
type: 'RSA',
bits: 4096
}.freeze
extend ActiveSupport::Concern
included do
validates :auth_method, inclusion: { in: %w[password ssh_public_key] }, allow_blank: true
# We should generate a key even if there's no SSH URL present
before_validation :generate_ssh_private_key!, if: -> {
regenerate_ssh_private_key || ( auth_method == 'ssh_public_key' && ssh_private_key.blank? )
}
credentials_field :auth_method, reader: false
credentials_field :ssh_known_hosts
credentials_field :ssh_known_hosts_verified_at
credentials_field :ssh_known_hosts_verified_by_id
credentials_field :ssh_private_key
credentials_field :user
credentials_field :password
end
class_methods do
def credentials_field(name, reader: true)
if reader
define_method(name) do
credentials[name] if credentials.present?
end
end
define_method("#{name}=") do |value|
self.credentials ||= {}
# Removal of the password, username, etc, generally causes an update of
# the value to the empty string. Detect and gracefully handle this case.
if value.present?
self.credentials[name] = value
else
self.credentials.delete(name)
end
end
end
end
attr_accessor :regenerate_ssh_private_key
def ssh_key_auth?
ssh_mirror_url? && auth_method == 'ssh_public_key'
end
def password_auth?
auth_method == 'password'
end
def ssh_mirror_url?
url&.start_with?('ssh://')
end
def ssh_known_hosts_verified_by
@ssh_known_hosts_verified_by ||= ::User.find_by(id: ssh_known_hosts_verified_by_id)
end
def ssh_known_hosts_fingerprints
::SshHostKey.fingerprint_host_keys(ssh_known_hosts)
end
def auth_method
auth_method = credentials.fetch(:auth_method, nil) if credentials.present?
auth_method.presence || 'password'
end
def ssh_public_key
return nil if ssh_private_key.blank?
comment = "git@#{::Gitlab.config.gitlab.host}"
::SSHKey.new(ssh_private_key, comment: comment).ssh_public_key
end
def generate_ssh_private_key!
self.ssh_private_key = ::SSHKey.generate(SSH_PRIVATE_KEY_OPTS).private_key
end
end
......@@ -2,6 +2,7 @@
class RemoteMirror < ActiveRecord::Base
include AfterCommitQueue
include MirrorAuthentication
PROTECTED_BACKOFF_DELAY = 1.minute
UNPROTECTED_BACKOFF_DELAY = 5.minutes
......@@ -28,6 +29,8 @@ class RemoteMirror < ActiveRecord::Base
after_commit :remove_remote, on: :destroy
before_validation :store_credentials
scope :enabled, -> { where(enabled: true) }
scope :started, -> { with_update_status(:started) }
scope :stuck, -> { started.where('last_update_at < ? OR (last_update_at IS NULL AND updated_at < ?)', 1.day.ago, 1.day.ago) }
......@@ -84,7 +87,21 @@ class RemoteMirror < ActiveRecord::Base
end
def update_repository(options)
raw.update(options)
if ssh_mirror_url?
if ssh_key_auth? && ssh_private_key.present?
options[:ssh_key] = ssh_private_key
end
if ssh_known_hosts.present?
options[:known_hosts] = ssh_known_hosts
end
end
Gitlab::Git::RemoteMirror.new(
project.repository.raw,
remote_name,
**options
).update
end
def sync?
......@@ -128,7 +145,8 @@ class RemoteMirror < ActiveRecord::Base
super(value) && return unless Gitlab::UrlSanitizer.valid?(value)
mirror_url = Gitlab::UrlSanitizer.new(value)
self.credentials = mirror_url.credentials
self.credentials ||= {}
self.credentials = self.credentials.merge(mirror_url.credentials)
super(mirror_url.sanitized_url)
end
......@@ -152,17 +170,28 @@ class RemoteMirror < ActiveRecord::Base
def ensure_remote!
return unless project
return unless remote_name && url
return unless remote_name && remote_url
# If this fails or the remote already exists, we won't know due to
# https://gitlab.com/gitlab-org/gitaly/issues/1317
project.repository.add_remote(remote_name, url)
project.repository.add_remote(remote_name, remote_url)
end
private
def raw
@raw ||= Gitlab::Git::RemoteMirror.new(project.repository.raw, remote_name)
def store_credentials
# This is a necessary workaround for attr_encrypted, which doesn't otherwise
# notice that the credentials have changed
self.credentials = self.credentials
end
# The remote URL omits any password if SSH public-key authentication is in use
def remote_url
return url unless ssh_key_auth? && password.present?
Gitlab::UrlSanitizer.new(read_attribute(:url), credentials: { user: user }).full_url
rescue
super
end
def fallback_remote_name
......@@ -214,7 +243,7 @@ class RemoteMirror < ActiveRecord::Base
project.repository.async_remove_remote(prev_remote_name)
end
project.repository.add_remote(remote_name, url)
project.repository.add_remote(remote_name, remote_url)
end
def remove_remote
......@@ -224,6 +253,6 @@ class RemoteMirror < ActiveRecord::Base
end
def mirror_url_changed?
url_changed? || encrypted_credentials_changed?
url_changed? || credentials_changed?
end
end
......@@ -3,11 +3,7 @@
class ProjectMirrorEntity < Grape::Entity
expose :id
expose :remote_mirrors_attributes do |project|
next [] unless project.remote_mirrors.present?
project.remote_mirrors.map do |remote|
remote.as_json(only: %i[id url enabled])
end
expose :remote_mirrors_attributes, using: RemoteMirrorEntity do |project|
project.remote_mirrors
end
end
# frozen_string_literal: true
class RemoteMirrorEntity < Grape::Entity
expose :id
expose :url
expose :enabled
expose :auth_method
expose :ssh_known_hosts
expose :ssh_public_key
expose :ssh_known_hosts_fingerprints do |remote_mirror|
remote_mirror.ssh_known_hosts_fingerprints.as_json
end
end
......@@ -11,7 +11,7 @@ module Projects
begin
remote_mirror.ensure_remote!
repository.fetch_remote(remote_mirror.remote_name, no_tags: true)
repository.fetch_remote(remote_mirror.remote_name, ssh_auth: remote_mirror, no_tags: true)
opts = {}
if remote_mirror.only_protected_branches?
......
- mirror = f.object
- is_push = local_assigns.fetch(:is_push, false)
- auth_options = [[_('Password'), 'password'], [_('SSH public key'), 'ssh_public_key']]
- regen_data = { auth_method: 'ssh_public_key', regenerate_ssh_private_key: true }
- ssh_public_key_present = mirror.ssh_public_key.present?
.form-group
= f.label :auth_method, _('Authentication method'), class: 'label-bold'
= f.select :auth_method,
options_for_select(auth_options, mirror.auth_method),
{}, { class: "form-control js-mirror-auth-type" }
.form-group
.collapse.js-well-changing-auth
.changing-auth-method= icon('spinner spin lg')
.well-password-auth.collapse.js-well-password-auth
= f.label :password, _("Password"), class: "label-bold"
= f.password_field :password, value: mirror.password, class: 'form-control'
- unless is_push
.well-ssh-auth.collapse.js-well-ssh-auth
%p.js-ssh-public-key-present{ class: ('collapse' unless ssh_public_key_present) }
= _('Here is the public SSH key that needs to be added to the remote server. For more information, please refer to the documentation.')
%p.js-ssh-public-key-pending{ class: ('collapse' if ssh_public_key_present) }
= _('An SSH key will be automatically generated when the form is submitted. For more information, please refer to the documentation.')
.clearfix.js-ssh-public-key-wrap{ class: ('collapse' unless ssh_public_key_present) }
%code.prepend-top-10.ssh-public-key
= mirror.ssh_public_key
= clipboard_button(text: mirror.ssh_public_key, title: _("Copy SSH public key to clipboard"), class: 'prepend-top-10 btn-copy-ssh-public-key')
= button_tag type: 'button',
data: { endpoint: project_mirror_path(@project), project_data: { import_data_attributes: regen_data } },
class: "btn btn-inverted btn-warning prepend-top-10 js-btn-regenerate-ssh-key#{ ' collapse' unless ssh_public_key_present }" do
= icon('spinner spin', class: 'js-spinner d-none')
= _('Regenerate key')
= render 'projects/mirrors/regenerate_public_ssh_key_confirm_modal'
......@@ -59,5 +59,7 @@
.badge.mirror-error-badge{ data: { toggle: 'tooltip', html: 'true' }, title: html_escape(mirror.last_error.try(:strip)) }= _('Error')
%td.mirror-action-buttons
.btn-group.mirror-actions-group.pull-right{ role: 'group' }
- if mirror.ssh_key_auth?
= clipboard_button(text: mirror.ssh_public_key, class: 'btn btn-default', title: _('Copy SSH public key'))
= render 'shared/remote_mirror_update_button', remote_mirror: mirror
%button.js-delete-mirror.btn.btn-danger{ type: 'button', data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' }, title: _('Remove') }= icon('trash-o')
- protocols = Gitlab::UrlSanitizer::ALLOWED_SCHEMES.join('|')
.form-group
= label_tag :mirror_direction, _('Mirror direction'), class: 'label-light'
= select_tag :mirror_direction, options_for_select([[_('Push'), 'push']]), class: 'form-control js-mirror-direction', disabled: true
= f.fields_for :remote_mirrors, @project.remote_mirrors.build do |rm_f|
= rm_f.hidden_field :enabled, value: '1'