Added support for external git credentials helper [BETA]

Fixes https://github.com/JetpackDuba/Gitnuro/issues/16
This commit is contained in:
Abdelilah El Aissaoui 2022-10-08 19:43:23 +02:00
parent c597624354
commit 7bdc2c4cf5
11 changed files with 219 additions and 27 deletions

View File

@ -1,19 +1,27 @@
package com.jetpackduba.gitnuro.credentials package com.jetpackduba.gitnuro.credentials
import com.jetpackduba.gitnuro.exceptions.NotSupportedHelper
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.eclipse.jgit.api.Git
import org.eclipse.jgit.lib.Config
import org.eclipse.jgit.transport.CredentialItem import org.eclipse.jgit.transport.CredentialItem
import org.eclipse.jgit.transport.CredentialsProvider import org.eclipse.jgit.transport.CredentialsProvider
import org.eclipse.jgit.transport.URIish import org.eclipse.jgit.transport.URIish
import javax.inject.Inject import java.io.*
import java.util.concurrent.TimeUnit
class HttpCredentialsProvider @Inject constructor( private const val TIMEOUT_MIN = 1L
private val credentialsStateManager: CredentialsStateManager
class HttpCredentialsProvider @AssistedInject constructor(
private val credentialsStateManager: CredentialsStateManager,
@Assisted val git: Git?,
) : CredentialsProvider() { ) : CredentialsProvider() {
override fun isInteractive(): Boolean { override fun isInteractive(): Boolean {
return true return true
} }
override fun supports(vararg items: CredentialItem?): Boolean { override fun supports(vararg items: CredentialItem?): Boolean {
println(items)
val fields = items.map { credentialItem -> credentialItem?.promptText } val fields = items.map { credentialItem -> credentialItem?.promptText }
return if (fields.isEmpty()) { return if (fields.isEmpty()) {
true true
@ -23,30 +31,199 @@ class HttpCredentialsProvider @Inject constructor(
fields.contains("Password") fields.contains("Password")
} }
override fun get(uri: URIish?, vararg items: CredentialItem?): Boolean { override fun get(uri: URIish, vararg items: CredentialItem): Boolean {
credentialsStateManager.updateState(CredentialsState.HttpCredentialsRequested) val userItem = items.firstOrNull { it.promptText == "Username" }
val passwordItem = items.firstOrNull { it.promptText == "Password" }
var credentials = credentialsStateManager.currentCredentialsState if (userItem !is CredentialItem.Username || passwordItem !is CredentialItem.Password) {
while (credentials is CredentialsState.CredentialsRequested) { return false
credentials = credentialsStateManager.currentCredentialsState
} }
val externalCredentialsHelper = getExternalCredentialsHelper(uri, git)
if (externalCredentialsHelper == null) {
val credentials = askForCredentials()
if (credentials is CredentialsState.HttpCredentialsAccepted) { if (credentials is CredentialsState.HttpCredentialsAccepted) {
val userItem = items.firstOrNull { it?.promptText == "Username" }
val passwordItem = items.firstOrNull { it?.promptText == "Password" }
if (userItem is CredentialItem.Username &&
passwordItem is CredentialItem.Password
) {
userItem.value = credentials.user userItem.value = credentials.user
passwordItem.value = credentials.password.toCharArray() passwordItem.value = credentials.password.toCharArray()
return true return true
} }
return false
} else {
when (handleExternalCredentialHelper(externalCredentialsHelper, uri, items)) {
ExternalCredentialsRequestResult.SUCCESS -> return true
ExternalCredentialsRequestResult.FAIL -> return false
ExternalCredentialsRequestResult.CREDENTIALS_NOT_STORED -> {
val credentials = askForCredentials()
if (credentials is CredentialsState.HttpCredentialsAccepted) {
userItem.value = credentials.user
passwordItem.value = credentials.password.toCharArray()
saveCredentialsInExternalHelper(uri, externalCredentialsHelper, credentials)
return true
} }
return false return false
} }
}
}
}
private fun saveCredentialsInExternalHelper(
uri: URIish,
externalCredentialsHelper: ExternalCredentialsHelper,
credentials: CredentialsState.HttpCredentialsAccepted
) {
val process = Runtime.getRuntime()
.exec(String.format("${externalCredentialsHelper.path} %s", "store"));
val output = process.outputStream // write to the input stream of the helper
val bufferedWriter = BufferedWriter(OutputStreamWriter(output))
bufferedWriter.use {
bufferedWriter.write("protocol=${uri.scheme}\n")
bufferedWriter.write("host=${uri.host}\n")
if (externalCredentialsHelper.useHttpPath) {
bufferedWriter.write("path=${uri.path}\n")
}
bufferedWriter.write("username=${credentials.user}\n")
bufferedWriter.write("password=${credentials.password}\n")
bufferedWriter.write("")
bufferedWriter.flush()
}
}
private fun askForCredentials(): CredentialsState {
credentialsStateManager.updateState(CredentialsState.HttpCredentialsRequested)
var credentials = credentialsStateManager.currentCredentialsState
while (credentials is CredentialsState.CredentialsRequested) {
credentials = credentialsStateManager.currentCredentialsState
}
return credentials
}
private fun handleExternalCredentialHelper(
externalCredentialsHelper: ExternalCredentialsHelper,
uri: URIish,
items: Array<out CredentialItem>
): ExternalCredentialsRequestResult {
val process = Runtime.getRuntime()
.exec(String.format("${externalCredentialsHelper.path} %s", "get"))
val output = process.outputStream // write to the input stream of the helper
val input = process.inputStream // reads from the output stream of the helper
val bufferedWriter = BufferedWriter(OutputStreamWriter(output))
val bufferedReader = BufferedReader(InputStreamReader(input))
bufferedWriter.use {
bufferedWriter.write("protocol=${uri.scheme}\n")
bufferedWriter.write("host=${uri.host}\n")
if (externalCredentialsHelper.useHttpPath) {
bufferedWriter.write("path=${uri.path}\n")
}
bufferedWriter.write("")
bufferedWriter.flush()
}
var usernameSet = false
var passwordSet = false
process.waitFor(TIMEOUT_MIN, TimeUnit.MINUTES)
// If the process is alive after $TIMEOUT_MIN, it means that it hasn't given an answer and then finished
if(process.isAlive) {
process.destroy()
return ExternalCredentialsRequestResult.FAIL
}
bufferedReader.use {
var line: String;
while (bufferedReader.readLine().also { line = it } != null && !(usernameSet && passwordSet)) {
if (line.startsWith("username=")) {
val split = line.split("=")
val userName = split.getOrNull(1) ?: return ExternalCredentialsRequestResult.CREDENTIALS_NOT_STORED
val userNameItem = items.firstOrNull { it.promptText == "Username" }
if (userNameItem is CredentialItem.Username) {
userNameItem.value = userName
usernameSet = true
}
} else if (line.startsWith("password=")) {
val split = line.split("=")
val password = split.getOrNull(1) ?: return ExternalCredentialsRequestResult.CREDENTIALS_NOT_STORED
val passwordItem = items.firstOrNull { it.promptText == "Password" }
if (passwordItem is CredentialItem.Password) {
passwordItem.value = password.toCharArray()
passwordSet = true
}
}
}
}
return if (usernameSet && passwordSet)
ExternalCredentialsRequestResult.SUCCESS
else
ExternalCredentialsRequestResult.CREDENTIALS_NOT_STORED
}
private fun getExternalCredentialsHelper(uri: URIish, git: Git?): ExternalCredentialsHelper? {
val config = if (git == null) {
val homePath = System.getProperty("user.home")
val configFile = File("$homePath/.gitconfig")
Config().apply {
if (configFile.exists()) {
fromText(configFile.readText())
}
}
} else {
git.repository.config
}
val hostWithProtocol = "${uri.scheme}://${uri.host}"
val genericCredentialHelper = config.getString("credential", null, "helper")
val uriSpecificCredentialHelper = config.getString("credential", hostWithProtocol, "helper")
val credentialHelperPath = uriSpecificCredentialHelper ?: genericCredentialHelper ?: return null
if(credentialHelperPath == "cache" || credentialHelperPath == "store") {
throw NotSupportedHelper("Invalid credentials: \"$credentialHelperPath\" is not yet supported")
}
// Use getString instead of getBoolean as boolean has a default value by we want null if the config field is not set
val uriSpecificUseHttpHelper = config.getString("credential", hostWithProtocol, "useHttpPath")
val genericUseHttpHelper = config.getBoolean("credential", "useHttpPath", false)
val useHttpPath = uriSpecificUseHttpHelper?.toBoolean() ?: genericUseHttpHelper
return ExternalCredentialsHelper(credentialHelperPath, useHttpPath)
}
}
data class ExternalCredentialsHelper(
val path: String,
val useHttpPath: Boolean,
)
enum class ExternalCredentialsRequestResult {
SUCCESS,
FAIL,
CREDENTIALS_NOT_STORED;
} }

View File

@ -0,0 +1,10 @@
package com.jetpackduba.gitnuro.di.factories
import com.jetpackduba.gitnuro.credentials.HttpCredentialsProvider
import dagger.assisted.AssistedFactory
import org.eclipse.jgit.api.Git
@AssistedFactory
interface HttpCredentialsFactory {
fun create(git: Git?) : HttpCredentialsProvider
}

View File

@ -0,0 +1,3 @@
package com.jetpackduba.gitnuro.exceptions
class NotSupportedHelper(message: String) : GitnuroException(message)

View File

@ -59,7 +59,7 @@ class CloneRepositoryUseCase @Inject constructor(
} }
} }
) )
.setTransportConfigCallback { handleTransportUseCase(it) } .setTransportConfigCallback { handleTransportUseCase(it, null) }
.call() .call()
ensureActive() ensureActive()

View File

@ -27,7 +27,7 @@ class DeleteRemoteBranchUseCase @Inject constructor(
val pushResults = git.push() val pushResults = git.push()
.setTransportConfigCallback { .setTransportConfigCallback {
handleTransportUseCase(it) handleTransportUseCase(it, git)
} }
.setRefSpecs(refSpec) .setRefSpecs(refSpec)
.setRemote(remoteName) .setRemote(remoteName)

View File

@ -16,7 +16,7 @@ class FetchAllBranchesUseCase @Inject constructor(
git.fetch() git.fetch()
.setRemote(remote.name) .setRemote(remote.name)
.setRefSpecs(remote.fetchRefSpecs) .setRefSpecs(remote.fetchRefSpecs)
.setTransportConfigCallback { handleTransportUseCase(it) } .setTransportConfigCallback { handleTransportUseCase(it, git) }
.setCredentialsProvider(CredentialsProvider.getDefault()) .setCredentialsProvider(CredentialsProvider.getDefault())
.call() .call()
} }

View File

@ -2,6 +2,8 @@ package com.jetpackduba.gitnuro.git.remote_operations
import com.jetpackduba.gitnuro.credentials.GSessionManager import com.jetpackduba.gitnuro.credentials.GSessionManager
import com.jetpackduba.gitnuro.credentials.HttpCredentialsProvider import com.jetpackduba.gitnuro.credentials.HttpCredentialsProvider
import com.jetpackduba.gitnuro.di.factories.HttpCredentialsFactory
import org.eclipse.jgit.api.Git
import org.eclipse.jgit.transport.HttpTransport import org.eclipse.jgit.transport.HttpTransport
import org.eclipse.jgit.transport.SshTransport import org.eclipse.jgit.transport.SshTransport
import org.eclipse.jgit.transport.Transport import org.eclipse.jgit.transport.Transport
@ -10,13 +12,13 @@ import javax.inject.Provider
class HandleTransportUseCase @Inject constructor( class HandleTransportUseCase @Inject constructor(
private val sessionManager: GSessionManager, private val sessionManager: GSessionManager,
private val httpCredentialsProvider: Provider<HttpCredentialsProvider>, private val httpCredentialsProvider: HttpCredentialsFactory,
) { ) {
operator fun invoke(transport: Transport?) { operator fun invoke(transport: Transport?, git: Git?) {
if (transport is SshTransport) { if (transport is SshTransport) {
transport.sshSessionFactory = sessionManager.generateSshSessionFactory() transport.sshSessionFactory = sessionManager.generateSshSessionFactory()
} else if (transport is HttpTransport) { } else if (transport is HttpTransport) {
transport.credentialsProvider = httpCredentialsProvider.get() transport.credentialsProvider = httpCredentialsProvider.create(git)
} }
} }
} }

View File

@ -13,7 +13,7 @@ class PullBranchUseCase @Inject constructor(
suspend operator fun invoke(git: Git, rebase: Boolean) = withContext(Dispatchers.IO) { suspend operator fun invoke(git: Git, rebase: Boolean) = withContext(Dispatchers.IO) {
val pullResult = git val pullResult = git
.pull() .pull()
.setTransportConfigCallback { handleTransportUseCase(it) } .setTransportConfigCallback { handleTransportUseCase(it, git) }
.setRebase(rebase) .setRebase(rebase)
.setCredentialsProvider(CredentialsProvider.getDefault()) .setCredentialsProvider(CredentialsProvider.getDefault())
.call() .call()

View File

@ -16,7 +16,7 @@ class PullFromSpecificBranchUseCase @Inject constructor(
suspend operator fun invoke(git: Git, rebase: Boolean, remoteBranch: Ref) = withContext(Dispatchers.IO) { suspend operator fun invoke(git: Git, rebase: Boolean, remoteBranch: Ref) = withContext(Dispatchers.IO) {
val pullResult = git val pullResult = git
.pull() .pull()
.setTransportConfigCallback { handleTransportUseCase(it) } .setTransportConfigCallback { handleTransportUseCase(it, git) }
.setRemote(remoteBranch.remoteName) .setRemote(remoteBranch.remoteName)
.setRemoteBranchName(remoteBranch.simpleName) .setRemoteBranchName(remoteBranch.simpleName)
.setRebase(rebase) .setRebase(rebase)

View File

@ -22,7 +22,7 @@ class PushBranchUseCase @Inject constructor(
if (pushTags) if (pushTags)
setPushTags() setPushTags()
} }
.setTransportConfigCallback { handleTransportUseCase(it) } .setTransportConfigCallback { handleTransportUseCase(it, git) }
.call() .call()
val results = val results =

View File

@ -27,7 +27,7 @@ class PushToSpecificBranchUseCase @Inject constructor(
if (pushTags) if (pushTags)
setPushTags() setPushTags()
} }
.setTransportConfigCallback { handleTransportUseCase(it) } .setTransportConfigCallback { handleTransportUseCase(it, git) }
.call() .call()
val results = val results =