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; + } + +}