diff --git a/platform/credential-store/src/credentialStore.kt b/platform/credential-store/src/credentialStore.kt index 470d4b54724d..bbd6d54e3966 100644 --- a/platform/credential-store/src/credentialStore.kt +++ b/platform/credential-store/src/credentialStore.kt @@ -16,6 +16,8 @@ package com.intellij.credentialStore import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.util.text.StringUtil +import com.intellij.util.nullize import java.security.MessageDigest import java.util.* @@ -32,4 +34,60 @@ internal fun getRawKey(key: String, requestor: Class<*>?) = if (requestor == nul internal fun toOldKey(hash: ByteArray) = "old-hashed-key|" + Base64.getEncoder().encodeToString(hash) -internal fun toOldKey(newKey: String) = toOldKey(MessageDigest.getInstance("SHA-256").digest(newKey.toByteArray())) \ No newline at end of file +internal fun toOldKey(newKey: String) = toOldKey(MessageDigest.getInstance("SHA-256").digest(newKey.toByteArray())) + +fun joinData(user: String?, password: String?) = "${StringUtil.escapeChars(user.orEmpty(), '\\', '@')}@$password" + +fun splitData(data: String): Credentials? { + if (data.isEmpty()) { + return null + } + + val list = parseString(data, '@') + val result = Credentials(list.getOrNull(0), list.getOrNull(1)) + return if (result.isFulfilled()) result else null +} + +private const val ESCAPING_CHAR = '\\' + +private fun parseString(data: String, delimiter: Char): List { + val part = StringBuilder() + val result = ArrayList(2) + var i = 0 + var c: Char? + do { + c = data.getOrNull(i++) + if (c != null && c != delimiter) { + if (c == ESCAPING_CHAR) { + c = data.getOrNull(i++) + } + + if (c != null) { + part.append(c) + continue + } + } + + result.add(part.toString()) + part.setLength(0) + } + while (c != null) + + return result +} + +class Credentials(user: String?, password: String?) { + val user = user.nullize() + val password = password.nullize() + + override fun equals(other: Any?): Boolean { + if (other !is Credentials) return false + return user == other.user && password == other.password + } + + override fun hashCode() = (user?.hashCode() ?: 0) * 37 + (password?.hashCode() ?: 0) + + override fun toString() = joinData(user, password) +} + +fun Credentials?.isFulfilled() = this != null && user != null && password != null \ No newline at end of file diff --git a/platform/credential-store/test/CredentialTest.kt b/platform/credential-store/test/CredentialTest.kt new file mode 100644 index 000000000000..7bd819b33479 --- /dev/null +++ b/platform/credential-store/test/CredentialTest.kt @@ -0,0 +1,20 @@ +package com.intellij.credentialStore + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +class CredentialTest { + @Test + fun join() { + test("foo", "pass", "foo@pass") + test("foo@", "pass", "foo\\@@pass") + test("\\foo@", "pass", "\\\\foo\\@@pass") + test("\\foo\\", "pass", "\\\\foo\\\\@pass") + test("", "pass", "@pass") + test("foo", "", "foo@") + } + + private fun test(u: String, p: String, joined: String) { + assertThat(joinData(u, p)).isEqualTo(joined) + assertThat(splitData(joined)).isEqualTo(Credentials(u, p)) + } +} \ No newline at end of file diff --git a/platform/platform-api/src/com/intellij/openapi/ui/dialogBuilder.kt b/platform/platform-api/src/com/intellij/openapi/ui/dialogBuilder.kt index 8f9ef15229d3..04f44c205ac6 100644 --- a/platform/platform-api/src/com/intellij/openapi/ui/dialogBuilder.kt +++ b/platform/platform-api/src/com/intellij/openapi/ui/dialogBuilder.kt @@ -17,13 +17,17 @@ package com.intellij.openapi.ui import javax.swing.JComponent -fun dialog(title: String, centerPanel: JComponent, resizable: Boolean = true): DialogBuilder { +fun dialog(title: String, centerPanel: JComponent, resizable: Boolean = true, preferedFocusComponent: JComponent? = null, okActionEnabled: Boolean = true): DialogBuilder { val builder = DialogBuilder() builder .title(title) .centerPanel(centerPanel) + .setPreferredFocusComponent(preferedFocusComponent) if (!resizable) { builder.resizable(false) } + if (!okActionEnabled) { + builder.okActionEnabled(false) + } return builder } \ No newline at end of file diff --git a/platform/platform-impl/src/com/intellij/ui/migLayout.kt b/platform/platform-impl/src/com/intellij/ui/migLayout.kt index 032c30bb707f..edb52bab4b63 100644 --- a/platform/platform-impl/src/com/intellij/ui/migLayout.kt +++ b/platform/platform-impl/src/com/intellij/ui/migLayout.kt @@ -63,7 +63,7 @@ enum class CCFlags { */ span, - grow, push, pushY, pushX, right + grow, push, pushY, pushX, right, skip } inline fun panel(vararg layoutConstraints: LCFlags, init: Panel.() -> Unit): JPanel { @@ -209,6 +209,7 @@ fun CC.apply(flags: Array): CC { CCFlags.pushY -> pushY() CCFlags.span -> span() + CCFlags.skip -> skip() } } return this diff --git a/platform/platform-impl/src/com/intellij/util/string.kt b/platform/platform-impl/src/com/intellij/util/string.kt index 042084d4e4ad..33ba59b3fe8a 100644 --- a/platform/platform-impl/src/com/intellij/util/string.kt +++ b/platform/platform-impl/src/com/intellij/util/string.kt @@ -17,6 +17,6 @@ package com.intellij.util import com.intellij.openapi.util.text.StringUtil -fun String?.nullize(): String? = StringUtil.nullize(this) +fun String?.nullize(nullizeSpaces: Boolean = false): String? = StringUtil.nullize(this, nullizeSpaces) fun String.trimMiddle(maxLength: Int): String? = StringUtil.trimMiddle(this, maxLength) \ No newline at end of file diff --git a/plugins/settings-repository/resources/messages/IcsBundle.properties b/plugins/settings-repository/resources/messages/IcsBundle.properties index d96c2cc7bc89..bcda0fd343cd 100644 --- a/plugins/settings-repository/resources/messages/IcsBundle.properties +++ b/plugins/settings-repository/resources/messages/IcsBundle.properties @@ -32,7 +32,7 @@ settings.update.on.start=Update repository from upstream on start sync.repositories.panel.title=Sync Repositories -login.github.note=Strongly recommended to use an access token. +login.github.note=How to create an access token. login.other.git.provider.note=Consider to configure git credentials helper. settings.upstream.url=Upstream URL\: diff --git a/plugins/settings-repository/settings-repository-tests.iml b/plugins/settings-repository/settings-repository-tests.iml index 52d3a8900571..8379717cd86d 100644 --- a/plugins/settings-repository/settings-repository-tests.iml +++ b/plugins/settings-repository/settings-repository-tests.iml @@ -24,5 +24,6 @@ + \ No newline at end of file diff --git a/plugins/settings-repository/src/CredentialsStore.kt b/plugins/settings-repository/src/CredentialsStore.kt index 0be494e1ec03..b2b351acafcb 100644 --- a/plugins/settings-repository/src/CredentialsStore.kt +++ b/plugins/settings-repository/src/CredentialsStore.kt @@ -15,39 +15,110 @@ */ package org.jetbrains.settingsRepository +import com.intellij.credentialStore.Credentials +import com.intellij.layout.* +import com.intellij.layout.CCFlags.* +import com.intellij.layout.LCFlags.* import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.ui.dialog import com.intellij.openapi.util.Computable -import com.intellij.openapi.util.text.StringUtil +import com.intellij.openapi.vcs.changes.issueLinks.LinkMouseListenerBase +import com.intellij.ui.DocumentAdapter +import com.intellij.ui.JBColor +import com.intellij.ui.SimpleColoredComponent +import com.intellij.ui.SimpleTextAttributes import com.intellij.util.PathUtilRt +import com.intellij.util.nullize +import com.intellij.util.trimMiddle import com.intellij.util.ui.UIUtil -import org.jetbrains.keychain.Credentials +import java.util.regex.Pattern +import javax.swing.JPasswordField +import javax.swing.JTextField +import javax.swing.event.DocumentEvent + +private val HREF_PATTERN = Pattern.compile("([^<]*)") +private val LINK_TEXT_ATTRIBUTES = SimpleTextAttributes(SimpleTextAttributes.STYLE_SMALLER, JBColor.blue) +private val SMALL_TEXT_ATTRIBUTES = SimpleTextAttributes(SimpleTextAttributes.STYLE_SMALLER, null) fun showAuthenticationForm(credentials: Credentials?, uri: String, host: String?, path: String?, sshKeyFile: String?): Credentials? { if (ApplicationManager.getApplication()?.isUnitTestMode === true) { throw AssertionError("showAuthenticationForm called from tests") } - return UIUtil.invokeAndWaitIfNeeded(Computable { - val note = if (sshKeyFile == null) icsMessage(if (host == "github.com") "login.github.note" else "login.other.git.provider.note") else null - var username = credentials?.id - if (username == null && host == "github.com" && path != null && sshKeyFile == null) { + val isGitHub = host == "github.com" + val note = if (sshKeyFile == null) icsMessage(if (isGitHub) "login.github.note" else "login.other.git.provider.note") else null + var username = credentials?.user + if (username == null && isGitHub && path != null && sshKeyFile == null) { val firstSlashIndex = path.indexOf('/', 1) username = path.substring(1, if (firstSlashIndex == -1) path.length else firstSlashIndex) } - val authenticationForm = RepositoryAuthenticationForm(if (sshKeyFile == null) { - icsMessage("log.in.to", StringUtil.trimMiddle(uri, 50)) + val message = if (sshKeyFile == null) icsMessage("log.in.to", uri.trimMiddle(50)) else icsMessage("enter.your.password.for.ssh.key", PathUtilRt.getFileName(sshKeyFile)) + + return UIUtil.invokeAndWaitIfNeeded(Computable { + val userField = JTextField(username) + val passwordField = JPasswordField(credentials?.password) + + val centerPanel = panel(fillX) { + label(message, wrap, span, bold = true, gapBottom = 10) + + if (sshKeyFile == null && !isGitHub) { + label("Username:") + userField(grow, wrap) + } + + label(if (sshKeyFile == null && isGitHub) "Token:" else "Password:") + passwordField(grow, wrap) + + note?.let { noteComponent(it)(skip) } } - else { - icsMessage("enter.your.password.for.ssh.key", PathUtilRt.getFileName(sshKeyFile)) - }, username, credentials?.token, note, sshKeyFile != null) + + val authenticationForm = dialog( + title = "Settings Repository", + resizable = false, + centerPanel = centerPanel, + preferedFocusComponent = userField, + okActionEnabled = false) + + passwordField.document.addDocumentListener(object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) { + authenticationForm.okActionEnabled(e.document.length != 0) + } + }) + authenticationForm.okActionEnabled(false) + if (authenticationForm.showAndGet()) { - username = sshKeyFile ?: authenticationForm.username - val passwordChars = authenticationForm.password - Credentials(username, if (passwordChars == null) (if (username == null) null else "x-oauth-basic") else String(passwordChars)) + username = sshKeyFile ?: userField.text.nullize(true) + val passwordChars = passwordField.password + Credentials(username, if (passwordChars == null || passwordChars.isEmpty()) (if (username == null) null else "x-oauth-basic") else String(passwordChars)) } else { null } }) +} + +private fun noteComponent(note: String): SimpleColoredComponent { + val noteComponent = SimpleColoredComponent() + + val matcher = HREF_PATTERN.matcher(note) + var prev = 0 + if (matcher.find()) { + do { + if (matcher.start() != prev) { + noteComponent.append(note.substring(prev, matcher.start()), SMALL_TEXT_ATTRIBUTES) + } + noteComponent.append(matcher.group(2), LINK_TEXT_ATTRIBUTES, SimpleColoredComponent.BrowserLauncherTag(matcher.group(1))) + prev = matcher.end() + } + while (matcher.find()) + + LinkMouseListenerBase.installSingleTagOn(noteComponent) + } + + if (prev < note.length) { + noteComponent.append(note.substring(prev), SMALL_TEXT_ATTRIBUTES) + } + + return noteComponent } \ No newline at end of file diff --git a/plugins/settings-repository/src/git/JGitCredentialsProvider.kt b/plugins/settings-repository/src/git/JGitCredentialsProvider.kt index 6d410fddf75f..c956fb76b565 100644 --- a/plugins/settings-repository/src/git/JGitCredentialsProvider.kt +++ b/plugins/settings-repository/src/git/JGitCredentialsProvider.kt @@ -15,6 +15,8 @@ */ package org.jetbrains.settingsRepository.git +import com.intellij.credentialStore.Credentials +import com.intellij.credentialStore.isFulfilled import com.intellij.credentialStore.macOs.isMacOsCredentialStoreSupported import com.intellij.openapi.ui.MessageDialogBuilder import com.intellij.openapi.ui.Messages @@ -25,9 +27,7 @@ import org.eclipse.jgit.lib.Repository import org.eclipse.jgit.transport.CredentialItem import org.eclipse.jgit.transport.CredentialsProvider import org.eclipse.jgit.transport.URIish -import org.jetbrains.keychain.Credentials import org.jetbrains.keychain.CredentialsStore -import org.jetbrains.keychain.isFulfilled import org.jetbrains.settingsRepository.LOG import org.jetbrains.settingsRepository.showAuthenticationForm @@ -116,10 +116,10 @@ class JGitCredentialsProvider(private val credentialsStore: NotNullLazyValue 0) { - val username = PasswordUtil.decodePassword(data.substring(0, separatorIndex)) - val password = PasswordUtil.decodePassword(data.substring(separatorIndex + 1)) - return Credentials(username, password) - } + return splitData(data) } else { return Credentials(sshKeyFile, data) } - - return null } override fun reset(host: String) { @@ -48,6 +40,6 @@ class FileCredentialsStore() : CredentialsStore { override fun save(host: String?, credentials: Credentials, sshKeyFile: String?) { val accountName: String = sshKeyFile ?: host!! - PasswordSafe.getInstance().setPassword("ics-" + accountName, if (sshKeyFile == null) "${PasswordUtil.encodePassword(credentials.id)}@${PasswordUtil.encodePassword(credentials.token)}" else credentials.token!!) + PasswordSafe.getInstance().setPassword("ics-" + accountName, if (sshKeyFile == null) credentials.toString() else credentials.password!!) } } \ No newline at end of file diff --git a/plugins/settings-repository/src/keychain/OsXCredentialsStore.kt b/plugins/settings-repository/src/keychain/OsXCredentialsStore.kt index 717dd7484826..2df11f3bb24f 100644 --- a/plugins/settings-repository/src/keychain/OsXCredentialsStore.kt +++ b/plugins/settings-repository/src/keychain/OsXCredentialsStore.kt @@ -15,6 +15,7 @@ */ package org.jetbrains.keychain +import com.intellij.credentialStore.Credentials import com.intellij.credentialStore.macOs.deleteGenericPassword import com.intellij.credentialStore.macOs.findGenericPassword import com.intellij.credentialStore.macOs.saveGenericPassword @@ -74,7 +75,7 @@ class OsXCredentialsStore(serviceName: String) : CredentialsStore { return } - val data = if (sshKeyFile == null) "${PasswordUtil.encodePassword(credentials.id)}@${PasswordUtil.encodePassword(credentials.token)}" else credentials.token!! + val data = if (sshKeyFile == null) credentials.toString() else credentials.password!! saveGenericPassword(getServiceName(sshKeyFile), accountName, data.toByteArray()) } diff --git a/plugins/settings-repository/src/org/jetbrains/settingsRepository/RepositoryAuthenticationForm.form b/plugins/settings-repository/src/org/jetbrains/settingsRepository/RepositoryAuthenticationForm.form deleted file mode 100644 index 04e47105bff7..000000000000 --- a/plugins/settings-repository/src/org/jetbrains/settingsRepository/RepositoryAuthenticationForm.form +++ /dev/null @@ -1,70 +0,0 @@ - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/plugins/settings-repository/src/org/jetbrains/settingsRepository/RepositoryAuthenticationForm.java b/plugins/settings-repository/src/org/jetbrains/settingsRepository/RepositoryAuthenticationForm.java deleted file mode 100644 index b00a3f16182a..000000000000 --- a/plugins/settings-repository/src/org/jetbrains/settingsRepository/RepositoryAuthenticationForm.java +++ /dev/null @@ -1,111 +0,0 @@ -package org.jetbrains.settingsRepository; - -import com.intellij.openapi.ui.DialogWrapper; -import com.intellij.openapi.util.text.StringUtil; -import com.intellij.openapi.vcs.changes.issueLinks.LinkMouseListenerBase; -import com.intellij.ui.DocumentAdapter; -import com.intellij.ui.JBColor; -import com.intellij.ui.SimpleColoredComponent; -import com.intellij.ui.SimpleTextAttributes; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import javax.swing.*; -import javax.swing.border.EmptyBorder; -import javax.swing.event.DocumentEvent; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -final class RepositoryAuthenticationForm extends DialogWrapper { - private static final Pattern HREF_PATTERN = Pattern.compile("([^<]*)"); - - private static final SimpleTextAttributes LINK_TEXT_ATTRIBUTES = new SimpleTextAttributes(SimpleTextAttributes.STYLE_SMALLER, JBColor.blue); - private static final SimpleTextAttributes SMALL_TEXT_ATTRIBUTES = new SimpleTextAttributes(SimpleTextAttributes.STYLE_SMALLER, null); - - private JTextField tokenField; - private SimpleColoredComponent noteComponent; - private JPasswordField passwordField; - - private JPanel panel; - private JLabel tokenLabel; - private JLabel messageLabel; - - private final JComponent initialFocusedComponent; - - public RepositoryAuthenticationForm(@NotNull String message, @Nullable String token, @Nullable String password, @Nullable String note, boolean onlyPassword) { - super(false); - - setTitle("Settings Repository"); - setResizable(false); - - messageLabel.setText(message); - messageLabel.setBorder(new EmptyBorder(0, 0, 10, 0)); - - if (onlyPassword) { - tokenLabel.setVisible(false); - tokenField.setVisible(false); - - passwordField.getDocument().addDocumentListener(new DocumentAdapter() { - @Override - protected void textChanged(DocumentEvent e) { - setOKActionEnabled(e.getDocument().getLength() != 0); - } - }); - initialFocusedComponent = passwordField; - setOKActionEnabled(false); - } - else { - tokenField.setText(token); - passwordField.setText(password); - initialFocusedComponent = StringUtil.isEmpty(token) ? tokenField : passwordField; - } - - if (note == null) { - noteComponent.setVisible(false); - } - else { - Matcher matcher = HREF_PATTERN.matcher(note); - int prev = 0; - if (matcher.find()) { - do { - if (matcher.start() != prev) { - noteComponent.append(note.substring(prev, matcher.start()), SMALL_TEXT_ATTRIBUTES); - } - noteComponent.append(matcher.group(2), LINK_TEXT_ATTRIBUTES, new SimpleColoredComponent.BrowserLauncherTag(matcher.group(1))); - prev = matcher.end(); - } - while (matcher.find()); - - LinkMouseListenerBase.installSingleTagOn(noteComponent); - } - - if (prev < note.length()) { - noteComponent.append(note.substring(prev), SMALL_TEXT_ATTRIBUTES); - } - } - - init(); - } - - @Nullable - @Override - public JComponent getPreferredFocusedComponent() { - return initialFocusedComponent; - } - - @Override - protected JComponent createCenterPanel() { - return panel; - } - - @Nullable - public String getUsername() { - return StringUtil.nullize(tokenField.getText(), true); - } - - @Nullable - public char[] getPassword() { - char[] chars = passwordField.getPassword(); - return chars == null || chars.length == 0 ? null : chars; - } -} diff --git a/plugins/settings-repository/testSrc/IcsCredentialTest.kt b/plugins/settings-repository/testSrc/IcsCredentialTest.kt index ae0f6799db6f..a8b47d302a53 100644 --- a/plugins/settings-repository/testSrc/IcsCredentialTest.kt +++ b/plugins/settings-repository/testSrc/IcsCredentialTest.kt @@ -1,12 +1,12 @@ package org.jetbrains.settingsRepository.test +import com.intellij.credentialStore.Credentials import com.intellij.openapi.util.NotNullLazyValue import com.intellij.testFramework.ApplicationRule import org.assertj.core.api.Assertions.assertThat import org.eclipse.jgit.storage.file.FileRepositoryBuilder import org.eclipse.jgit.transport.CredentialItem import org.eclipse.jgit.transport.URIish -import org.jetbrains.keychain.Credentials import org.jetbrains.keychain.CredentialsStore import org.jetbrains.keychain.FileCredentialsStore import org.jetbrains.settingsRepository.git.JGitCredentialsProvider