From 296555274e1c7151a1dcebec933066f0096f1d84 Mon Sep 17 00:00:00 2001 From: Kirill Likhodedov Date: Sat, 9 Mar 2013 17:32:44 +0400 Subject: [PATCH] [git] Implement ability to use the native Git for HTTP(S) connection Use native command line Git for HTTP connections via GIT_ASKPASS environment variable, to be able to get rid of the JGit library. Method description. The communication logic is very similar to what is already implemented for SSH connections. * Define the GIT_ASKPASS environment variable pointing to a script generated by the ScriptGenerator. * When Git requests username and/or password for an HTTP connection, it calls the script defined in GIT_ASKPASS. * The script starts the Java application GitAskPassApp and passes the Git request to it as command line arguments. Git process waits for the app to return user credentials to the app's stdout. * GitAskPassApp requests the credentials via XML RPC from the main IDEA instance by calling correspondent methods of the GitAskPassXmlRpcHandler. * Before Git command is called, a GitHttpAuthenticator is registered in the GitHttpAuthService to receive any XML RPC requests about HTTP authentication. * Once the XML RPC request is received, the GitHttpAuthenticator either takes the password from the password safe, or shows a prompt to the user in a modal dialog. * Response is provided back to the GitAskPassXmlRpcClient, which returns it to the GitAskPassApp, which in turn returns it to the Git process. GitHttpAuthenticator details: * The key for password safe is url + login (see makeKey()). * If password is asked, then the URL contains the login inside URL, and the key is ready. * If username is asked the key is constructed and is searched for in the settings (GitRememberedInputs). * If no username is specified in the URL, Git queries for the username and for the password consecutively. In this case to avoid showing dialogs twice, ask for both credentials at once (AuthDialog) and remember the password to provide it to the Git process during the next request. * We can't store the credentials enter by user in the settings and the password safe right after they have been entered, because user can enter incorrect credentials. Therefore, listen to the Git process output (GitHandler#addAuthListener) and analyze whether authentication was successful or failed. If it was successful, ask GitHttpAuthenticator to remember the correct credentials, otherwise forget them (since they could have been taken from the password safe, not from the user). * Because of this, all other data (url and login, or url with login if the password was requested) is saved to the instance fields. This change fixes all issues opened because of JGit problems: * authorization issues: IDEA-97178, IDEA-102201, IDEA-100617, IDEA-98189 * connection and proxy issues: IDEA-83984, IDEA-92519, IDEA-96205 * IDEA-77411 self-signed server certificates (although we can provide more user-friendliness by proposing to set http.sslVerify to false in the UI). * IDEA-100096 client SSL certificates. * other: IDEA-78744, IDEA-77992, IDEA-76982, IDEA-78310, IDEA-86083, IDEA-92163, IDEA-94167, IDEA-99159, IDEA-100097 Known drawback: No retry. If user enters incorrect credentials, he will see the failure notification and will need start operation again to supply correct data this time. --- .../src/com/intellij/util/io/URLUtil.java | 18 ++ .../git4idea/http/GitAskPassApp.java | 100 +++++++++ .../git4idea/http/GitAskPassXmlRpcClient.java | 80 +++++++ .../http/GitAskPassXmlRpcHandler.java | 55 +++++ plugins/git4idea/src/META-INF/plugin.xml | 2 + .../src/git4idea/commands/GitHandler.java | 46 ++++ .../git4idea/commands/GitHttpAuthService.java | 74 +++++++ .../commands/GitHttpAuthenticator.java | 206 ++++++++++++++++++ 8 files changed, 581 insertions(+) create mode 100644 plugins/git4idea/rt/src/org/jetbrains/git4idea/http/GitAskPassApp.java create mode 100644 plugins/git4idea/rt/src/org/jetbrains/git4idea/http/GitAskPassXmlRpcClient.java create mode 100644 plugins/git4idea/rt/src/org/jetbrains/git4idea/http/GitAskPassXmlRpcHandler.java create mode 100644 plugins/git4idea/src/git4idea/commands/GitHttpAuthService.java create mode 100644 plugins/git4idea/src/git4idea/commands/GitHttpAuthenticator.java diff --git a/platform/util/src/com/intellij/util/io/URLUtil.java b/platform/util/src/com/intellij/util/io/URLUtil.java index 51f01d4c939d..363bf554ad8f 100644 --- a/platform/util/src/com/intellij/util/io/URLUtil.java +++ b/platform/util/src/com/intellij/util/io/URLUtil.java @@ -16,6 +16,7 @@ package com.intellij.util.io; +import com.intellij.openapi.util.Pair; import com.intellij.openapi.util.io.FileUtil; import gnu.trove.TIntArrayList; import org.jetbrains.annotations.NonNls; @@ -147,4 +148,21 @@ public class URLUtil { public static boolean containsScheme(String url) { return url.contains("://"); } + + /** + * Splits the url into 2 parts: the scheme ("http://" for instance) and the rest of the URL. + * The scheme can be null. + */ + @NotNull + public static Pair splitScheme(@NotNull String url) { + final String schemeSeparator = "://"; + int ind = url.indexOf(schemeSeparator); + if (ind >= 0) { + String scheme = url.substring(0, ind + schemeSeparator.length()); + return Pair.create(scheme, url.substring(ind)); + } + else { + return Pair.create(null, url); + } + } } diff --git a/plugins/git4idea/rt/src/org/jetbrains/git4idea/http/GitAskPassApp.java b/plugins/git4idea/rt/src/org/jetbrains/git4idea/http/GitAskPassApp.java new file mode 100644 index 000000000000..38d8ba855a0c --- /dev/null +++ b/plugins/git4idea/rt/src/org/jetbrains/git4idea/http/GitAskPassApp.java @@ -0,0 +1,100 @@ +/* + * Copyright 2000-2013 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jetbrains.git4idea.http; + +import com.intellij.openapi.util.Pair; +import org.jetbrains.annotations.NotNull; + +/** + *

This is a program that would be called by Git when an HTTP connection is needed, that requires authorization, + * and if {@code GIT_ASKPASS} variable is set to the script that invokes this program.

+ *

The program is called separately for each authorization aspect. + * I. e. if no username is specified, then it is started and queried for the username, and then started once again for the password.

+ *

Query format is the following: + *

+ *

+ *

Git expects the reply from the program's standard output.

+ * + * @author Kirill Likhodedov + */ +public class GitAskPassApp { + + // STDOUT is used to provide credentials to Git process; STDERR is used to print error message to the main IDEA command line. + @SuppressWarnings("UseOfSystemOutOrSystemErr") + public static void main(String[] args) { + try { + if (args.length < 1) { + throw new IllegalArgumentException("No arguments specified!"); + } + + Pair arguments = parseArguments(args[0]); + boolean usernameNeeded = arguments.getFirst(); + String url = arguments.getSecond(); + + int handler = Integer.parseInt(getNotNull(GitAskPassXmlRpcHandler.GIT_ASK_PASS_HANDLER_ENV)); + int xmlRpcPort = Integer.parseInt(getNotNull(GitAskPassXmlRpcHandler.GIT_ASK_PASS_PORT_ENV)); + GitAskPassXmlRpcClient xmlRpcClient = new GitAskPassXmlRpcClient(xmlRpcPort); + + if (usernameNeeded) { + String username = xmlRpcClient.askUsername(handler, url); + System.out.println(username); + } + else { + String pass = xmlRpcClient.askPassword(handler, url); + System.out.println(pass); + } + } + catch (Throwable t) { + System.err.println(t.getMessage()); + t.printStackTrace(System.err); + } + } + + @NotNull + private static String getNotNull(@NotNull String env) { + String handlerValue = System.getenv(env); + if (handlerValue == null) { + throw new IllegalStateException(env + " environment variable is not defined!"); + } + return handlerValue; + } + + @NotNull + private static Pair parseArguments(@NotNull String arg) { + String[] split = arg.split(" "); + if (split.length < 3) { + throw new IllegalArgumentException("Unknown argument format: " + arg); + } + boolean username = split[0].equalsIgnoreCase("username"); + String url = split[2]; + // un-quote and remove the trailing colon + if (url.startsWith("'")) { + url = url.substring(1); + } + if (url.endsWith(":")) { + url = url.substring(0, url.length() - 1); + } + if (url.endsWith("'")) { + url = url.substring(0, url.length() - 1); + } + return Pair.create(username, url); + } + +} diff --git a/plugins/git4idea/rt/src/org/jetbrains/git4idea/http/GitAskPassXmlRpcClient.java b/plugins/git4idea/rt/src/org/jetbrains/git4idea/http/GitAskPassXmlRpcClient.java new file mode 100644 index 000000000000..5d3f8681f099 --- /dev/null +++ b/plugins/git4idea/rt/src/org/jetbrains/git4idea/http/GitAskPassXmlRpcClient.java @@ -0,0 +1,80 @@ +/* + * Copyright 2000-2013 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jetbrains.git4idea.http; + +import org.apache.xmlrpc.XmlRpcClientLite; +import org.apache.xmlrpc.XmlRpcException; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.util.Vector; + +/** + * Calls {@link GitAskPassXmlRpcHandler} methods via XML RPC. + * + * @author Kirill Likhodedov + */ +class GitAskPassXmlRpcClient { + + @NotNull private final XmlRpcClientLite myClient; + + GitAskPassXmlRpcClient(int port) throws MalformedURLException { + myClient = new XmlRpcClientLite("127.0.0.1", port); + } + + // Obsolete collection usage because of the XmlRpcClientLite API + @SuppressWarnings({"UseOfObsoleteCollectionType", "unchecked"}) + String askUsername(int handler, @NotNull String url) { + Vector parameters = new Vector(); + parameters.add(handler); + parameters.add(url); + + try { + return (String)myClient.execute(methodName("askUsername"), parameters); + } + catch (XmlRpcException e) { + throw new RuntimeException("Invocation failed " + e.getMessage(), e); + } + catch (IOException e) { + throw new RuntimeException("Invocation failed " + e.getMessage(), e); + } + } + + // Obsolete collection usage because of the XmlRpcClientLite API + @SuppressWarnings({"UseOfObsoleteCollectionType", "unchecked"}) + String askPassword(int handler, @NotNull String url) { + Vector parameters = new Vector(); + parameters.add(handler); + parameters.add(url); + + try { + return (String)myClient.execute(methodName("askPassword"), parameters); + } + catch (XmlRpcException e) { + throw new RuntimeException("Invocation failed " + e.getMessage(), e); + } + catch (IOException e) { + throw new RuntimeException("Invocation failed " + e.getMessage(), e); + } + } + + @NotNull + private static String methodName(@NotNull String method) { + return GitAskPassXmlRpcHandler.HANDLER_NAME + "." + method; + } + +} diff --git a/plugins/git4idea/rt/src/org/jetbrains/git4idea/http/GitAskPassXmlRpcHandler.java b/plugins/git4idea/rt/src/org/jetbrains/git4idea/http/GitAskPassXmlRpcHandler.java new file mode 100644 index 000000000000..36cc365c1d80 --- /dev/null +++ b/plugins/git4idea/rt/src/org/jetbrains/git4idea/http/GitAskPassXmlRpcHandler.java @@ -0,0 +1,55 @@ +/* + * Copyright 2000-2013 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jetbrains.git4idea.http; + +import org.jetbrains.annotations.NotNull; + +/** + * This handler is called via XML RPC from {@link GitAskPassApp} when Git requests user credentials. + * + * @author Kirill Likhodedov + */ +public interface GitAskPassXmlRpcHandler { + + String GIT_ASK_PASS_ENV = "GIT_ASKPASS"; + String GIT_ASK_PASS_HANDLER_ENV = "GIT_ASKPASS_HANDLER"; + String GIT_ASK_PASS_PORT_ENV = "GIT_ASKPASS_PORT"; + String HANDLER_NAME = GitAskPassXmlRpcHandler.class.getName(); + + /** + * Get the username from the user to access the given URL. + * @param handler XML RPC handler number. + * @param url URL which Git tries to access. + * @return The Username which should be used for the URL. + */ + // UnusedDeclaration suppressed: the method is used via XML RPC + @SuppressWarnings("UnusedDeclaration") + @NotNull + String askUsername(int handler, @NotNull String url); + + /** + * Get the password from the user to access the given URL. + * It is assumed that the username either is specified in the URL (http://username@host.com), or has been asked earlier. + * @param handler XML RPC handler number. + * @param url URL which Git tries to access. + * @return The password which should be used for the URL. + */ + // UnusedDeclaration suppressed: the method is used via XML RPC + @SuppressWarnings("UnusedDeclaration") + @NotNull + String askPassword(int handler, @NotNull String url); + +} diff --git a/plugins/git4idea/src/META-INF/plugin.xml b/plugins/git4idea/src/META-INF/plugin.xml index 85242d0449cd..f6f784cea289 100644 --- a/plugins/git4idea/src/META-INF/plugin.xml +++ b/plugins/git4idea/src/META-INF/plugin.xml @@ -151,6 +151,8 @@ serviceImplementation="git4idea.config.GitVcsApplicationSettings"/> + { + + @NotNull + @Override + protected String getScriptTempFilePrefix() { + return "git-askpass-"; + } + + @NotNull + @Override + protected Class getScriptMainClass() { + return GitAskPassApp.class; + } + + @Override + protected void customizeScriptGenerator(@NotNull ScriptGenerator generator) { + } + + @NotNull + @Override + protected String getRpcHandlerName() { + return GitAskPassXmlRpcHandler.HANDLER_NAME; + } + + @NotNull + @Override + protected Object createRpcRequestHandlerDelegate() { + return new InternalRequestHandlerDelegate(); + } + + /** + * Internal handler implementation class, it is made public to be accessible via XML RPC. + */ + public class InternalRequestHandlerDelegate implements GitAskPassXmlRpcHandler { + @NotNull + @Override + public String askUsername(int handler, @NotNull String url) { + return getHandler(handler).askUsername(url); + } + + @NotNull + @Override + public String askPassword(int handler, @NotNull String url) { + return getHandler(handler).askPassword(url); + } + } + +} diff --git a/plugins/git4idea/src/git4idea/commands/GitHttpAuthenticator.java b/plugins/git4idea/src/git4idea/commands/GitHttpAuthenticator.java new file mode 100644 index 000000000000..cd292ca82b52 --- /dev/null +++ b/plugins/git4idea/src/git4idea/commands/GitHttpAuthenticator.java @@ -0,0 +1,206 @@ +/* + * Copyright 2000-2013 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package git4idea.commands; + +import com.intellij.ide.passwordSafe.PasswordSafe; +import com.intellij.ide.passwordSafe.PasswordSafeException; +import com.intellij.ide.passwordSafe.impl.PasswordSafeImpl; +import com.intellij.ide.passwordSafe.ui.PasswordSafePromptDialog; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.application.ModalityState; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Pair; +import com.intellij.openapi.util.text.StringUtil; +import com.intellij.util.AuthData; +import com.intellij.util.io.URLUtil; +import com.intellij.vcsUtil.AuthDialog; +import git4idea.jgit.GitHttpAuthDataProvider; +import git4idea.remote.GitRememberedInputs; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + *

Handles "ask username" and "ask password" requests from Git: + * shows authentication dialog in the GUI, waits for user input and returns the credentials supplied by the user.

+ *

If user cancels the dialog, empty string is returned.

+ *

If no username is specified in the URL, Git queries for the username and for the password consecutively. + * In this case to avoid showing dialogs twice, the component asks for both credentials at once, + * and remembers the password to provide it to the Git process during the next request without requiring user interaction.

+ *

New instance of the GitAskPassGuiHandler should be created for each session, i. e. for each remote operation call.

+ * + * @author Kirill Likhodedov + */ +class GitHttpAuthenticator { + + private static final Logger LOG = Logger.getInstance(GitHttpAuthenticator.class); + private static final Class PASS_REQUESTER = GitHttpAuthenticator.class; + + @NotNull private final Project myProject; + @Nullable private final ModalityState myModalityState; + @NotNull private final String myTitle; + + @Nullable private String myPassword; + @Nullable private String myPasswordKey; + @Nullable private String myUrl; + @Nullable private String myLogin; + private boolean myRememberOnDisk; + + GitHttpAuthenticator(@NotNull Project project, @Nullable ModalityState modalityState, @NotNull GitCommand command) { + myProject = project; + myModalityState = modalityState; + myTitle = "Git " + StringUtil.capitalize(command.name()); + } + + @NotNull + String askPassword(@NotNull String url) { + if (myPassword != null) { // already asked in askUsername + return myPassword; + } + String prompt = "Enter the password for " + url; + String key = adjustHttpUrl(url); + myPasswordKey = key; + return PasswordSafePromptDialog.askPassword(myProject, myModalityState, myTitle, prompt, PASS_REQUESTER, key, false, null); + } + + @NotNull + String askUsername(@NotNull String url) { + String key = adjustHttpUrl(url); + AuthData authData = getSavedAuthData(myProject, key); + String login = null; + String password = null; + if (authData != null) { + login = authData.getLogin(); + password = authData.getPassword(); + } + if (login != null && password != null) { + myPassword = password; + return login; + } + + final AuthDialog dialog = new AuthDialog(myProject, myTitle, "Enter credentials for " + url, login, null, true); + ApplicationManager.getApplication().invokeAndWait(new Runnable() { + @Override + public void run() { + dialog.show(); + } + }, myModalityState == null ? ModalityState.defaultModalityState() : myModalityState); + + if (!dialog.isOK()) { + return ""; + } + + // remember values to store in the database afterwards, if authentication succeeds + myPassword = dialog.getPassword(); + myLogin = dialog.getUsername(); + myUrl = key; + myRememberOnDisk = dialog.isRememberPassword(); + myPasswordKey = makeKey(myUrl, myLogin); + + return myLogin; + } + + void saveAuthData() { + // save login and url + if (myUrl != null && myLogin != null) { + GitRememberedInputs.getInstance().addUrl(myUrl, myLogin); + } + + // save password + if (myPasswordKey != null && myPassword != null) { + PasswordSafeImpl passwordSafe = (PasswordSafeImpl)PasswordSafe.getInstance(); + try { + passwordSafe.getMemoryProvider().storePassword(myProject, PASS_REQUESTER, myPasswordKey, myPassword); + if (myRememberOnDisk) { + passwordSafe.getMasterKeyProvider().storePassword(myProject, PASS_REQUESTER, myPasswordKey, myPassword); + } + } + catch (PasswordSafeException e) { + LOG.error("Couldn't remember password for " + myPasswordKey, e); + } + } + } + + void forgetPassword() { + if (myPasswordKey != null) { + try { + PasswordSafe.getInstance().removePassword(myProject, PASS_REQUESTER, myPasswordKey); + } + catch (PasswordSafeException e) { + LOG.info("Couldn't forget the password for " + myPasswordKey); + } + } + } + + /** + * If the url scheme is HTTPS, store it as HTTP in the database, not to make user enter and remember same credentials twice. + */ + @NotNull + private static String adjustHttpUrl(@NotNull String url) { + String prefix = "https"; + if (url.startsWith(prefix)) { + return "http" + url.substring(prefix.length()); + } + return url; + } + + @Nullable + private static AuthData getSavedAuthData(@NotNull Project project, @NotNull String url) { + String userName = GitRememberedInputs.getInstance().getUserNameForUrl(url); + if (userName == null) { + return trySavedAuthDataFromProviders(url); + } + String key = makeKey(url, userName); + final PasswordSafe passwordSafe = PasswordSafe.getInstance(); + try { + String password = passwordSafe.getPassword(project, PASS_REQUESTER, key); + if (password != null) { + return new AuthData(userName, password); + } + return null; + } + catch (PasswordSafeException e) { + LOG.info("Couldn't get the password for key [" + key + "]", e); + return null; + } + } + + @Nullable + private static AuthData trySavedAuthDataFromProviders(@NotNull String url) { + GitHttpAuthDataProvider[] extensions = GitHttpAuthDataProvider.EP_NAME.getExtensions(); + for (GitHttpAuthDataProvider provider : extensions) { + AuthData authData = provider.getAuthData(url); + if (authData != null) { + return authData; + } + } + return null; + } + + /** + * Makes the password database key for the URL: inserts the login after the scheme: http://login@url. + */ + @NotNull + private static String makeKey(@NotNull String url, @NotNull String login) { + Pair pair = URLUtil.splitScheme(url); + String scheme = pair.getFirst(); + if (scheme != null) { + return scheme + login + "@" + pair.getSecond(); + } + return login + "@" + url; + } + +}