feat: add support for default and git credentials helper as preferred SSH key options in the UI

This commit is contained in:
Nikita Galaiko 2023-12-14 11:18:13 +01:00 committed by GitButler
parent 9b00b5fe7d
commit 4ddc970747
6 changed files with 224 additions and 273 deletions

View File

@ -148,7 +148,6 @@ fn main() {
commands::git_set_global_config, commands::git_set_global_config,
commands::git_get_global_config, commands::git_get_global_config,
commands::project_flush_and_push, commands::project_flush_and_push,
commands::test_git_connection,
zip::commands::get_logs_archive_path, zip::commands::get_logs_archive_path,
zip::commands::get_project_archive_path, zip::commands::get_project_archive_path,
zip::commands::get_project_data_archive_path, zip::commands::get_project_data_archive_path,

View File

@ -155,59 +155,3 @@ pub async fn project_flush_and_push(handle: tauri::AppHandle, id: &str) -> Resul
Ok(()) Ok(())
} }
#[tauri::command(async)]
#[instrument(skip(handle))]
pub async fn test_git_connection(
handle: tauri::AppHandle,
project_id: &str,
) -> Result<serde_json::Value, Error> {
let project_id = project_id.parse().map_err(|_| Error::UserError {
code: Code::Validation,
message: "Malformed project id".into(),
})?;
let users = handle.state::<users::Controller>().inner().clone();
let projects = handle.state::<projects::Controller>().inner().clone();
let local_data_dir = DataDir::try_from(&handle)?;
let project = projects.get(&project_id).context("failed to get project")?;
let user = users.get_user()?;
let project_repository = project_repository::Repository::open(&project)?;
let gb_repo =
gb_repository::Repository::open(&local_data_dir, &project_repository, user.as_ref())
.context("failed to open repository")?;
let default_target = gb_repo
.default_target()
.context("failed to get default target")?
.ok_or_else(|| Error::UserError {
code: Code::Branches,
message: "No default target set".into(),
})?;
let remote_name = default_target.branch.remote();
let helper = git::credentials::Helper::from(&handle);
let mut results = HashMap::new();
let refspec = &format!("+refs/heads/*:refs/remotes/{}/*", remote_name);
for (mut remote, credentials) in helper.enumerate(&project_repository, remote_name)? {
let remote_url = remote.url().context("failed to get remote url")?.unwrap();
let results = results
.entry(remote_url.to_string())
.or_insert_with(Vec::new);
for callback in credentials {
let mut fetch_opts = git2::FetchOptions::new();
fetch_opts.remote_callbacks(callback.clone().into());
fetch_opts.prune(git2::FetchPrune::On);
results.push(serde_json::json!({
"callback": format!("{:?}", callback),
"error": remote.fetch(&[refspec], Some(&mut fetch_opts)).err().map(|e| e.to_string()),
}));
}
}
Ok(serde_json::json!(results))
}

View File

@ -15,7 +15,6 @@ pub enum SshCredential {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum HttpsCredential { pub enum HttpsCredential {
UsernamePassword { username: String, password: String },
CredentialHelper { username: String, password: String }, CredentialHelper { username: String, password: String },
GitHubToken(String), GitHubToken(String),
} }
@ -53,12 +52,6 @@ impl From<Credential> for git2::RemoteCallbacks<'_> {
git2::Cred::ssh_key_from_memory("git", None, &key.to_string(), None) git2::Cred::ssh_key_from_memory("git", None, &key.to_string(), None)
}); });
} }
Credential::Https(HttpsCredential::UsernamePassword { username, password }) => {
remote_callbacks.credentials(move |url, _username_from_url, _allowed_types| {
tracing::info!("authenticating with {url} as '{username}' with password");
git2::Cred::userpass_plaintext(&username, &password)
});
}
Credential::Https(HttpsCredential::CredentialHelper { username, password }) => { Credential::Https(HttpsCredential::CredentialHelper { username, password }) => {
remote_callbacks.credentials(move |url, _username_from_url, _allowed_types| { remote_callbacks.credentials(move |url, _username_from_url, _allowed_types| {
tracing::info!("authenticating with {url} as '{username}' with password using credential helper"); tracing::info!("authenticating with {url} as '{username}' with password using credential helper");
@ -147,87 +140,6 @@ impl Helper {
} }
} }
/// returns all possible credentials for a remote, without trying to be smart.
pub fn enumerate<'a>(
&'a self,
project_repository: &'a project_repository::Repository,
remote: &str,
) -> Result<Vec<(super::Remote, Vec<Credential>)>, HelpError> {
let remote = project_repository.git_repository.find_remote(remote)?;
let remote_url = remote.url()?.ok_or(HelpError::NoUrlSet)?;
let mut flow = vec![];
if let projects::AuthKey::Local {
private_key_path,
passphrase,
} = &project_repository.project().preferred_key
{
let ssh_remote = if remote_url.scheme == super::Scheme::Ssh {
project_repository
.git_repository
.remote_anonymous(&remote_url)
} else {
let ssh_url = remote_url.as_ssh()?;
project_repository.git_repository.remote_anonymous(&ssh_url)
}?;
flow.push((
ssh_remote,
vec![Credential::Ssh(SshCredential::Keyfile {
key_path: private_key_path.clone(),
passphrase: passphrase.clone(),
})],
));
}
// is github is authenticated, only try github.
if remote_url.is_github() {
if let Some(github_access_token) = self
.users
.get_user()?
.and_then(|user| user.github_access_token)
{
let https_remote = if remote_url.scheme == super::Scheme::Https {
project_repository
.git_repository
.remote_anonymous(&remote_url)
} else {
let ssh_url = remote_url.as_ssh()?;
project_repository.git_repository.remote_anonymous(&ssh_url)
}?;
flow.push((
https_remote,
vec![Credential::Https(HttpsCredential::GitHubToken(
github_access_token,
))],
));
}
}
if let Ok(https_url) = remote_url.as_https() {
flow.push((
project_repository
.git_repository
.remote_anonymous(&https_url)?,
Self::https_flow(project_repository, &https_url)?
.into_iter()
.map(Credential::Https)
.collect(),
));
}
if let Ok(ssh_url) = remote_url.as_ssh() {
flow.push((
project_repository
.git_repository
.remote_anonymous(&ssh_url)?,
self.ssh_flow()?.into_iter().map(Credential::Ssh).collect(),
));
}
Ok(flow)
}
pub fn help<'a>( pub fn help<'a>(
&'a self, &'a self,
project_repository: &'a project_repository::Repository, project_repository: &'a project_repository::Repository,
@ -241,146 +153,174 @@ impl Helper {
return Ok(vec![(remote, vec![Credential::Noop])]); return Ok(vec![(remote, vec![Credential::Noop])]);
} }
// if prefernce set, only try that. match &project_repository.project().preferred_key {
if let projects::AuthKey::Local { projects::AuthKey::Local {
private_key_path, private_key_path,
passphrase, passphrase,
} = &project_repository.project().preferred_key } => {
{ let ssh_remote = if remote_url.scheme == super::Scheme::Ssh {
let ssh_remote = if remote_url.scheme == super::Scheme::Ssh { Ok(remote)
Ok(remote) } else {
} else { let ssh_url = remote_url.as_ssh()?;
let ssh_url = remote_url.as_ssh()?; project_repository.git_repository.remote_anonymous(&ssh_url)
project_repository.git_repository.remote_anonymous(&ssh_url) }?;
}?;
return Ok(vec![( Ok(vec![(
ssh_remote, ssh_remote,
vec![Credential::Ssh(SshCredential::Keyfile { vec![Credential::Ssh(SshCredential::Keyfile {
key_path: private_key_path.clone(), key_path: private_key_path.clone(),
passphrase: passphrase.clone(), passphrase: passphrase.clone(),
})], })],
)]); )])
} }
projects::AuthKey::Generated => {
let ssh_remote = if remote_url.scheme == super::Scheme::Ssh {
Ok(remote)
} else {
let ssh_url = remote_url.as_ssh()?;
project_repository.git_repository.remote_anonymous(&ssh_url)
}?;
// is github is authenticated, only try github. let key = self.keys.get_or_create()?;
if remote_url.is_github() { Ok(vec![(
if let Some(github_access_token) = self ssh_remote,
.users vec![Credential::Ssh(SshCredential::GitButlerKey(Box::new(key)))],
.get_user()? )])
.and_then(|user| user.github_access_token) }
{ projects::AuthKey::GitCredentialsHelper => {
let https_remote = if remote_url.scheme == super::Scheme::Https { let https_remote = if remote_url.scheme == super::Scheme::Https {
Ok(remote) Ok(remote)
} else { } else {
let url = remote_url.as_https()?; let url = remote_url.as_https()?;
project_repository.git_repository.remote_anonymous(&url) project_repository.git_repository.remote_anonymous(&url)
}?; }?;
return Ok(vec![( let flow = Self::https_flow(project_repository, &remote_url)?
https_remote,
vec![Credential::Https(HttpsCredential::GitHubToken(
github_access_token,
))],
)]);
}
}
match remote_url.scheme {
super::Scheme::Https => {
let mut flow = vec![];
let https_flow = Self::https_flow(project_repository, &remote_url)?
.into_iter() .into_iter()
.map(Credential::Https) .map(Credential::Https)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
Ok(vec![(https_remote, flow)])
if !https_flow.is_empty() {
flow.push((remote, https_flow));
}
if let Ok(ssh_url) = remote_url.as_ssh() {
let ssh_flow = self
.ssh_flow()?
.into_iter()
.map(Credential::Ssh)
.collect::<Vec<_>>();
if !ssh_flow.is_empty() {
flow.push((
project_repository
.git_repository
.remote_anonymous(&ssh_url)?,
ssh_flow,
));
}
}
Ok(flow)
} }
super::Scheme::Ssh => { projects::AuthKey::Default => {
let mut flow = vec![]; // is github is authenticated, only try github.
if remote_url.is_github() {
let ssh_flow = self if let Some(github_access_token) = self
.ssh_flow()? .users
.into_iter() .get_user()?
.map(Credential::Ssh) .and_then(|user| user.github_access_token)
.collect::<Vec<_>>(); {
if !ssh_flow.is_empty() { let https_remote = if remote_url.scheme == super::Scheme::Https {
flow.push((remote, ssh_flow)); Ok(remote)
} } else {
let url = remote_url.as_https()?;
if let Ok(https_url) = remote_url.as_https() { project_repository.git_repository.remote_anonymous(&url)
let https_flow = Self::https_flow(project_repository, &https_url)? }?;
.into_iter() return Ok(vec![(
.map(Credential::Https) https_remote,
.collect::<Vec<_>>(); vec![Credential::Https(HttpsCredential::GitHubToken(
if !https_flow.is_empty() { github_access_token,
flow.push(( ))],
project_repository )]);
.git_repository
.remote_anonymous(&https_url)?,
https_flow,
));
} }
} }
Ok(flow) match remote_url.scheme {
} super::Scheme::Https => {
_ => { let mut flow = vec![];
let mut flow = vec![];
if let Ok(https_url) = remote_url.as_https() { let https_flow = Self::https_flow(project_repository, &remote_url)?
let https_flow = Self::https_flow(project_repository, &https_url)? .into_iter()
.into_iter() .map(Credential::Https)
.map(Credential::Https) .collect::<Vec<_>>();
.collect::<Vec<_>>();
if !https_flow.is_empty() { if !https_flow.is_empty() {
flow.push(( flow.push((remote, https_flow));
project_repository }
.git_repository
.remote_anonymous(&https_url)?, if let Ok(ssh_url) = remote_url.as_ssh() {
https_flow, let ssh_flow = self
)); .ssh_flow()?
.into_iter()
.map(Credential::Ssh)
.collect::<Vec<_>>();
if !ssh_flow.is_empty() {
flow.push((
project_repository
.git_repository
.remote_anonymous(&ssh_url)?,
ssh_flow,
));
}
}
Ok(flow)
}
super::Scheme::Ssh => {
let mut flow = vec![];
let ssh_flow = self
.ssh_flow()?
.into_iter()
.map(Credential::Ssh)
.collect::<Vec<_>>();
if !ssh_flow.is_empty() {
flow.push((remote, ssh_flow));
}
if let Ok(https_url) = remote_url.as_https() {
let https_flow = Self::https_flow(project_repository, &https_url)?
.into_iter()
.map(Credential::Https)
.collect::<Vec<_>>();
if !https_flow.is_empty() {
flow.push((
project_repository
.git_repository
.remote_anonymous(&https_url)?,
https_flow,
));
}
}
Ok(flow)
}
_ => {
let mut flow = vec![];
if let Ok(https_url) = remote_url.as_https() {
let https_flow = Self::https_flow(project_repository, &https_url)?
.into_iter()
.map(Credential::Https)
.collect::<Vec<_>>();
if !https_flow.is_empty() {
flow.push((
project_repository
.git_repository
.remote_anonymous(&https_url)?,
https_flow,
));
}
}
if let Ok(ssh_url) = remote_url.as_ssh() {
let ssh_flow = self
.ssh_flow()?
.into_iter()
.map(Credential::Ssh)
.collect::<Vec<_>>();
if !ssh_flow.is_empty() {
flow.push((
project_repository
.git_repository
.remote_anonymous(&ssh_url)?,
ssh_flow,
));
}
}
Ok(flow)
} }
} }
if let Ok(ssh_url) = remote_url.as_ssh() {
let ssh_flow = self
.ssh_flow()?
.into_iter()
.map(Credential::Ssh)
.collect::<Vec<_>>();
if !ssh_flow.is_empty() {
flow.push((
project_repository
.git_repository
.remote_anonymous(&ssh_url)?,
ssh_flow,
));
}
}
Ok(flow)
} }
} }
} }

View File

@ -8,7 +8,9 @@ use crate::{git, id::Id, types::default_true::DefaultTrue};
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum AuthKey { pub enum AuthKey {
#[default] #[default]
Default,
Generated, Generated,
GitCredentialsHelper,
Local { Local {
private_key_path: path::PathBuf, private_key_path: path::PathBuf,
passphrase: Option<String>, passphrase: Option<String>,

View File

@ -12,7 +12,9 @@ import {
} from 'rxjs'; } from 'rxjs';
export type Key = export type Key =
| 'default'
| 'generated' | 'generated'
| 'gitCredentialsHelper'
| { | {
local: { private_key_path: string; passphrase?: string }; local: { private_key_path: string; passphrase?: string };
}; };

View File

@ -24,12 +24,28 @@
sshKey = key; sshKey = key;
}); });
let selectedOption = project.preferred_key === 'generated' ? 'generated' : 'local'; let selectedOption =
project.preferred_key === 'generated'
? 'generated'
: project.preferred_key === 'default'
? 'default'
: project.preferred_key === 'gitCredentialsHelper'
? 'gitCredentialsHelper'
: 'local';
let privateKeyPath = let privateKeyPath =
project.preferred_key === 'generated' ? '' : project.preferred_key.local.private_key_path; project.preferred_key === 'generated' ||
project.preferred_key === 'default' ||
project.preferred_key === 'gitCredentialsHelper'
? ''
: project.preferred_key.local.private_key_path;
let privateKeyPassphrase = let privateKeyPassphrase =
project.preferred_key === 'generated' ? '' : project.preferred_key.local.passphrase; project.preferred_key === 'generated' ||
project.preferred_key === 'default' ||
project.preferred_key === 'gitCredentialsHelper'
? ''
: project.preferred_key.local.passphrase;
function setLocalKey() { function setLocalKey() {
dispatch('updated', { dispatch('updated', {
preferred_key: { preferred_key: {
@ -44,6 +60,18 @@
}); });
} }
function setGitCredentialsHelperKey() {
dispatch('updated', {
preferred_key: 'gitCredentialsHelper'
});
}
function setDefaultKey() {
dispatch('updated', {
preferred_key: 'default'
});
}
function setGeneratedKey() { function setGeneratedKey() {
dispatch('updated', { dispatch('updated', {
preferred_key: 'generated' preferred_key: 'generated'
@ -52,7 +80,7 @@
</script> </script>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<p>Preferred SSH Key</p> <p>Git Authentication</p>
<div class="pr-8 text-sm text-light-700 dark:text-dark-200"> <div class="pr-8 text-sm text-light-700 dark:text-dark-200">
<div> <div>
Select the SSH key that GitButler will use to authenticate with your Git provider. These keys Select the SSH key that GitButler will use to authenticate with your Git provider. These keys
@ -61,8 +89,44 @@
</div> </div>
<div class="grid grid-cols-2 gap-2" style="grid-template-columns: max-content 1fr;"> <div class="grid grid-cols-2 gap-2" style="grid-template-columns: max-content 1fr;">
<input type="radio" bind:group={selectedOption} value="generated" on:input={setGeneratedKey} /> <input type="radio" bind:group={selectedOption} value="default" on:input={setDefaultKey} />
<div class="flex flex-col space-y-2">
<div>Default</div>
{#if selectedOption === 'default'}
<div class="pr-8">
<div>
We will try all of: your local SHH keys, git credentials helper and local GitButler key.
</div>
<div>
It is recommended to select a specific authenticatation flow for a better expirience.
</div>
</div>
{/if}
</div>
<input
type="radio"
bind:group={selectedOption}
value="gitCredentialsHelper"
on:input={setGitCredentialsHelperKey}
/>
<div class="flex flex-col space-y-2">
<div class="pr-8">
<div>
Use <a href="https://git-scm.com/doc/credential-helpers">git credentials helper</a>
</div>
</div>
{#if selectedOption === 'gitCredentialsHelper'}
<div>
<div>
We will use the system git credentials helper to authenticate with your Git provider.
</div>
</div>
{/if}
</div>
<input type="radio" bind:group={selectedOption} value="generated" on:input={setGeneratedKey} />
<div class="flex flex-col space-y-2"> <div class="flex flex-col space-y-2">
<div class="pr-8"> <div class="pr-8">
<div>Use locally generated SSH key</div> <div>Use locally generated SSH key</div>
@ -103,7 +167,7 @@
{/if} {/if}
</div> </div>
<input type="radio" bind:group={selectedOption} value="local" on:input={() => setLocalKey} /> <input type="radio" bind:group={selectedOption} value="local" on:input={setLocalKey} />
<div class="flex flex-col space-y-2"> <div class="flex flex-col space-y-2">
<div>Use existing SSH key</div> <div>Use existing SSH key</div>
@ -121,7 +185,7 @@
<label for="path">Path to private key</label> <label for="path">Path to private key</label>
<TextBox <TextBox
placeholder="/absolute/path/id_rsa" placeholder="~/.ssh/id_rsa"
bind:value={privateKeyPath} bind:value={privateKeyPath}
on:change={setLocalKey} on:change={setLocalKey}
/> />