From 4d11c23029047a5f9916fe40ab8d826257443ecc Mon Sep 17 00:00:00 2001 From: Wagyourtail Date: Mon, 25 Mar 2024 00:15:28 -0500 Subject: [PATCH] implement auth --- build.gradle.kts | 3 + .../unimined/api/runs/RunConfig.kt | 3 +- .../unimined/api/runs/RunsConfig.kt | 2 + .../unimined/api/runs/auth/AuthConfig.kt | 50 ++++ .../xyz/wagyourtail/unimined/util/Utils.kt | 5 + .../InterfaceInjectionMinecraftTransformer.kt | 2 +- .../internal/minecraft/MinecraftProvider.kt | 3 +- .../minecraft/resolver/VersionData.kt | 19 +- .../unimined/internal/runs/RunsProvider.kt | 2 + .../internal/runs/auth/AuthProvider.kt | 222 ++++++++++++++++++ testing/1.8.9-Forge-Fabric/gradle.properties | 5 +- 11 files changed, 305 insertions(+), 11 deletions(-) create mode 100644 src/api/kotlin/xyz/wagyourtail/unimined/api/runs/auth/AuthConfig.kt create mode 100644 src/runs/kotlin/xyz/wagyourtail/unimined/internal/runs/auth/AuthProvider.kt diff --git a/build.gradle.kts b/build.gradle.kts index 1637836c..450f1a03 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -179,6 +179,9 @@ dependencies { implementation("org.eclipse.jgit:org.eclipse.jgit:5.13.2.202306221912-r") + implementation("com.github.javakeyring:java-keyring:1.0.3") + implementation("net.raphimc:MinecraftAuth:4.0.0") + compileOnly("org.jetbrains.kotlinx:kotlinx-metadata-jvm:0.4.2") { isTransitive = false } diff --git a/src/api/kotlin/xyz/wagyourtail/unimined/api/runs/RunConfig.kt b/src/api/kotlin/xyz/wagyourtail/unimined/api/runs/RunConfig.kt index 918d7637..f2ba365e 100644 --- a/src/api/kotlin/xyz/wagyourtail/unimined/api/runs/RunConfig.kt +++ b/src/api/kotlin/xyz/wagyourtail/unimined/api/runs/RunConfig.kt @@ -8,6 +8,7 @@ import org.gradle.api.tasks.SourceSet import org.gradle.api.tasks.TaskContainer import org.gradle.jvm.toolchain.JavaLanguageVersion import org.gradle.jvm.toolchain.JavaToolchainService +import xyz.wagyourtail.unimined.api.runs.auth.AuthConfig import xyz.wagyourtail.unimined.util.XMLBuilder import xyz.wagyourtail.unimined.util.withSourceSet import java.io.File @@ -31,7 +32,7 @@ data class RunConfig( var workingDir: File, val env: MutableMap, val runFirst: MutableList = mutableListOf(), - var disabled : Boolean = false, + var disabled : Boolean = false ) { fun createIdeaRunConfig() { diff --git a/src/api/kotlin/xyz/wagyourtail/unimined/api/runs/RunsConfig.kt b/src/api/kotlin/xyz/wagyourtail/unimined/api/runs/RunsConfig.kt index 0491d73d..0255c1b9 100644 --- a/src/api/kotlin/xyz/wagyourtail/unimined/api/runs/RunsConfig.kt +++ b/src/api/kotlin/xyz/wagyourtail/unimined/api/runs/RunsConfig.kt @@ -3,6 +3,7 @@ package xyz.wagyourtail.unimined.api.runs import groovy.lang.Closure import groovy.lang.DelegatesTo import org.jetbrains.annotations.ApiStatus +import xyz.wagyourtail.unimined.api.runs.auth.AuthConfig import xyz.wagyourtail.unimined.util.FinalizeOnRead abstract class RunsConfig { @@ -10,6 +11,7 @@ abstract class RunsConfig { * just a flag to disable all. */ var off: Boolean by FinalizeOnRead(false) + abstract val auth: AuthConfig fun config( config: String, diff --git a/src/api/kotlin/xyz/wagyourtail/unimined/api/runs/auth/AuthConfig.kt b/src/api/kotlin/xyz/wagyourtail/unimined/api/runs/auth/AuthConfig.kt new file mode 100644 index 00000000..f0c8a2c5 --- /dev/null +++ b/src/api/kotlin/xyz/wagyourtail/unimined/api/runs/auth/AuthConfig.kt @@ -0,0 +1,50 @@ +package xyz.wagyourtail.unimined.api.runs.auth + +import org.jetbrains.annotations.ApiStatus +import java.util.* + +/** + * Configuration for authentication. + * it is recommended to use the gradle properties (possibly globally) instead of setting these values in the build.gradle + * + * @since 1.2.0 + */ +interface AuthConfig { + + /** + * If enabled, unimined will use https://github.com/RaphiMC/MinecraftAuth to authenticate client runs with Mojang. + * value can also be set by the `unimined.auth.enabled` gradle property. but this overrides the value set there. + */ + var enabled: Boolean + + /** + * If enabled, unimined will store the credentials to global gradle cache/unimined/auth.json + * this value can also be set by the `unimined.auth.storeCredentials` gradle property. + * if enabled, it will default to the first account in the store, or the `unimined.auth.username` gradle property. + * if the username specified by the property is not found, it will prompt for login. + * + * to make not enabling this less annoying, unimined will always store the credentials in-memory for the gradle daemon. + */ + var storeCredentials: Boolean + + /** + * when enabled, unimined will store a key for decrypting the credential file to the os's keychain using java-keyring. + * if disabled, the tokens will be stored in plain text!!! + * this value can also be set by the `unimined.auth.encryptStoredCredentials` gradle property. + * + * do note, that due to OS limitations, the keyring may be fully accessible to other java applications, + * or even other applications entirely. see: https://github.com/javakeyring/java-keyring?tab=readme-ov-file#security-concerns + */ + var encryptStoredCredentials: Boolean + + @get:ApiStatus.Internal + @set:ApiStatus.Experimental + var authInfo: AuthInfo? + + data class AuthInfo( + val username: String, + val uuid: UUID, + val accessToken: String, + ) + +} \ No newline at end of file diff --git a/src/api/kotlin/xyz/wagyourtail/unimined/util/Utils.kt b/src/api/kotlin/xyz/wagyourtail/unimined/util/Utils.kt index 04fa9549..2f7d2a9e 100644 --- a/src/api/kotlin/xyz/wagyourtail/unimined/util/Utils.kt +++ b/src/api/kotlin/xyz/wagyourtail/unimined/util/Utils.kt @@ -85,6 +85,11 @@ object OSUtils { "x86_64" -> "64" else -> "unknown" } + + const val WINDOWS = "windows" + const val LINUX = "linux" + const val OSX = "osx" + const val UNKNOWN = "unknown" } fun testSha1(size: Long, sha1: String, path: Path): Boolean { diff --git a/src/mapping/kotlin/xyz/wagyourtail/unimined/internal/mapping/ii/InterfaceInjectionMinecraftTransformer.kt b/src/mapping/kotlin/xyz/wagyourtail/unimined/internal/mapping/ii/InterfaceInjectionMinecraftTransformer.kt index 6790239c..fdebbd6f 100644 --- a/src/mapping/kotlin/xyz/wagyourtail/unimined/internal/mapping/ii/InterfaceInjectionMinecraftTransformer.kt +++ b/src/mapping/kotlin/xyz/wagyourtail/unimined/internal/mapping/ii/InterfaceInjectionMinecraftTransformer.kt @@ -37,7 +37,7 @@ object InterfaceInjectionMinecraftTransformer { reader.accept(node, 0) if (node.interfaces == null) { - node.interfaces = arrayListOf(); + node.interfaces = arrayListOf() } for (injected in injections[target]!!) { diff --git a/src/minecraft/kotlin/xyz/wagyourtail/unimined/internal/minecraft/MinecraftProvider.kt b/src/minecraft/kotlin/xyz/wagyourtail/unimined/internal/minecraft/MinecraftProvider.kt index b1b80120..d07ee2aa 100644 --- a/src/minecraft/kotlin/xyz/wagyourtail/unimined/internal/minecraft/MinecraftProvider.kt +++ b/src/minecraft/kotlin/xyz/wagyourtail/unimined/internal/minecraft/MinecraftProvider.kt @@ -684,7 +684,8 @@ class MinecraftProvider(project: Project, sourceSet: SourceSet) : MinecraftConfi minecraftData.metadata.getGameArgs( "Dev", workingDirectory.toPath(), - assetsDir + assetsDir, + runs.auth.authInfo ), (minecraftData.metadata.getJVMArgs( workingDirectory.resolve("libraries").toPath(), diff --git a/src/minecraft/kotlin/xyz/wagyourtail/unimined/internal/minecraft/resolver/VersionData.kt b/src/minecraft/kotlin/xyz/wagyourtail/unimined/internal/minecraft/resolver/VersionData.kt index 722d5a28..90bfb13d 100644 --- a/src/minecraft/kotlin/xyz/wagyourtail/unimined/internal/minecraft/resolver/VersionData.kt +++ b/src/minecraft/kotlin/xyz/wagyourtail/unimined/internal/minecraft/resolver/VersionData.kt @@ -4,6 +4,7 @@ import com.google.gson.JsonArray import com.google.gson.JsonElement import com.google.gson.JsonObject import org.gradle.api.JavaVersion +import xyz.wagyourtail.unimined.api.runs.auth.AuthConfig import xyz.wagyourtail.unimined.util.OSUtils import xyz.wagyourtail.unimined.util.consumerApply import java.net.MalformedURLException @@ -145,6 +146,7 @@ data class VersionData( username: String, gameDir: Path, assets: Path, + authInfo: AuthConfig.AuthInfo? ): MutableList { val args = getArgsRecursive() return applyGameArgs( @@ -154,7 +156,8 @@ data class VersionData( assets, assetIndex?.id ?: "", id, - type!! + type!!, + authInfo ) } } @@ -166,22 +169,24 @@ fun applyGameArgs( assets: Path, assetIndex: String, id: String, - type: String + type: String, + authInfo: AuthConfig.AuthInfo? ): MutableList { - return args.mapNotNull { e: String -> - if (e == "--uuid" || e == "\${auth_uuid}") null - else e.replace("\${auth_player_name}", username) + return args.asSequence().mapNotNull { e: String -> + if (authInfo == null && (e == "--uuid" || e == "\${auth_uuid}")) null + else e.replace("\${auth_player_name}", authInfo?.username ?: username) .replace("\${version_name}", id) .replace("\${game_directory}", gameDir.toAbsolutePath().toString()) .replace("\${assets_root}", assets.toString()) .replace("\${game_assets}", gameDir.resolve("resources").toString()) .replace("\${assets_index_name}", assetIndex) .replace("\${assets_index}", assetIndex) - .replace("\${auth_access_token}", "0") - .replace("\${clientid}", "0") + .replace("\${auth_access_token}", authInfo?.accessToken ?: "0") + .replace("\${clientid}", "unimined") .replace("\${user_type}", "msa") .replace("\${version_type}", type) .replace("\${user_properties}", "{}") + .replace("\${auth_uuid}", authInfo?.uuid.toString()) }.toMutableList() } diff --git a/src/runs/kotlin/xyz/wagyourtail/unimined/internal/runs/RunsProvider.kt b/src/runs/kotlin/xyz/wagyourtail/unimined/internal/runs/RunsProvider.kt index ae466712..553da0c8 100644 --- a/src/runs/kotlin/xyz/wagyourtail/unimined/internal/runs/RunsProvider.kt +++ b/src/runs/kotlin/xyz/wagyourtail/unimined/internal/runs/RunsProvider.kt @@ -5,6 +5,7 @@ import xyz.wagyourtail.unimined.api.minecraft.MinecraftConfig import xyz.wagyourtail.unimined.api.runs.RunConfig import xyz.wagyourtail.unimined.api.runs.RunsConfig import xyz.wagyourtail.unimined.api.unimined +import xyz.wagyourtail.unimined.internal.runs.auth.AuthProvider import xyz.wagyourtail.unimined.util.defaultedMapOf import xyz.wagyourtail.unimined.util.sourceSets import xyz.wagyourtail.unimined.util.withSourceSet @@ -12,6 +13,7 @@ import xyz.wagyourtail.unimined.util.withSourceSet class RunsProvider(val project: Project, val minecraft: MinecraftConfig) : RunsConfig() { private var freeze = false + override val auth = AuthProvider(this) private val runConfigs = mutableMapOf() private val transformers = defaultedMapOf Unit> { {} } diff --git a/src/runs/kotlin/xyz/wagyourtail/unimined/internal/runs/auth/AuthProvider.kt b/src/runs/kotlin/xyz/wagyourtail/unimined/internal/runs/auth/AuthProvider.kt new file mode 100644 index 00000000..abb7e973 --- /dev/null +++ b/src/runs/kotlin/xyz/wagyourtail/unimined/internal/runs/auth/AuthProvider.kt @@ -0,0 +1,222 @@ +package xyz.wagyourtail.unimined.internal.runs.auth + +import com.github.javakeyring.Keyring +import com.github.javakeyring.PasswordAccessException +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import net.lenni0451.commons.httpclient.HttpClient +import net.raphimc.minecraftauth.MinecraftAuth +import net.raphimc.minecraftauth.step.msa.StepMsaDeviceCode +import xyz.wagyourtail.unimined.api.runs.auth.AuthConfig +import xyz.wagyourtail.unimined.api.unimined +import xyz.wagyourtail.unimined.internal.runs.RunsProvider +import xyz.wagyourtail.unimined.util.FinalizeOnRead +import xyz.wagyourtail.unimined.util.LazyMutable +import xyz.wagyourtail.unimined.util.OSUtils +import java.io.ByteArrayInputStream +import java.security.SecureRandom +import java.util.* +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.io.path.deleteIfExists +import kotlin.io.path.exists +import kotlin.io.path.readBytes +import kotlin.io.path.writeBytes + +class AuthProvider(val runProvider: RunsProvider) : AuthConfig { + override var enabled: Boolean by FinalizeOnRead((runProvider.project.findProperty("unimined.auth.enabled") as String?)?.toBoolean() ?: false) + override var storeCredentials: Boolean by FinalizeOnRead((runProvider.project.findProperty("unimined.auth.storeCredentials") as String?)?.toBoolean() ?: true) + override var encryptStoredCredentials: Boolean by FinalizeOnRead((runProvider.project.findProperty("unimined.auth.encryptStoredCredentials") as String?)?.toBoolean() ?: true) + + companion object { + private var daemonCache2: JsonObject? = null + } + + val client = HttpClient().apply { + setHeader("User-Agent", "Wagyourtail/Unimined 1.0 ()") + } + + fun passwordGenerator(): String { + val secureRandom = SecureRandom() + // use base64 encoding, 256 bits + val randomBytes = ByteArray(32) + secureRandom.nextBytes(randomBytes) + return Base64.getEncoder().encodeToString(randomBytes) + } + + fun passwordToBytes(password: String): ByteArray { + return Base64.getDecoder().decode(password) + } + + fun encryptAES256(data: ByteArray, key: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + val secretKey = SecretKeySpec(key, "AES") + cipher.init(Cipher.ENCRYPT_MODE, secretKey, IvParameterSpec(ByteArray(16))) + return cipher.doFinal(data) + } + + fun decryptAES256(data: ByteArray, key: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + val secretKey = SecretKeySpec(key, "AES") + cipher.init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(ByteArray(16))) + return cipher.doFinal(data) + } + + override var authInfo: AuthConfig.AuthInfo? by FinalizeOnRead(LazyMutable { + if (!enabled) return@LazyMutable null + + runProvider.project.logger.lifecycle("[Unimined/Auth] Getting Auth Info") + val authFile = runProvider.project.unimined.getGlobalCache().resolve("auth.json") + val encAuthFile = runProvider.project.unimined.getGlobalCache().resolve("auth.json.enc") + + val existingCredList = if (daemonCache2 != null) { + runProvider.project.logger.lifecycle("[Unimined/Auth] Using in-memory cached auth from previous run") + daemonCache2 + } else if (storeCredentials) { + val fileBytes = if (authFile.exists() && encryptStoredCredentials) { + // re-store encrypted + try { + val password = getOrSetPassword() + // encrypt auth.json + val authData = authFile.readBytes() + val encData = encryptAES256(authData, passwordToBytes(password)) + encAuthFile.writeBytes(encData) + + // delete old auth.json + authFile.deleteIfExists() + + authData + } catch (e: Exception) { + runProvider.project.logger.error("[Unimined/Auth] Failed writing encrypted session data, no credentials will be stored!", e) + null + } + } else if (encryptStoredCredentials && encAuthFile.exists()) { + // get key from keychain + val encKey = getOrSetPassword() + val encData = encAuthFile.readBytes() + try { + decryptAES256(encData, passwordToBytes(encKey)) + } catch (e: Exception) { + runProvider.project.logger.error("[Unimined/Auth] Failed decrypting session data!", e) + null + } + } else if (!encryptStoredCredentials && authFile.exists()) { + authFile.readBytes() + } else { + null + } + // read to json + if (fileBytes != null) { + try { + JsonParser.parseReader(ByteArrayInputStream(fileBytes).reader()) + } catch (e: Exception) { + runProvider.project.logger.error("[Unimined/Auth] Failed reading session data!", e) + null + } + } else { + null + } + } else { + null + } + + val existingCreds = if (existingCredList != null) { + val username = runProvider.project.findProperty("unimined.auth.username") as String? + if (username != null) { + existingCredList.asJsonObject.get(username) + } else { + existingCredList.asJsonObject.entrySet().firstOrNull()?.value + } + } else { + null + } + + val session = if (existingCreds != null) { + try { + val session = MinecraftAuth.JAVA_DEVICE_CODE_LOGIN.fromJson(existingCreds.asJsonObject) + runProvider.project.logger.lifecycle("Refreshing auth") + MinecraftAuth.JAVA_DEVICE_CODE_LOGIN.refresh(client, session) + session + } catch (e: Exception) { + runProvider.project.logger.lifecycle("[Unimined/Auth] $existingCreds") + throw e + } + } else { + runProvider.project.logger.lifecycle("[Unimined/Auth] Logging in to Minecraft!") + MinecraftAuth.JAVA_DEVICE_CODE_LOGIN.getFromInput(client, StepMsaDeviceCode.MsaDeviceCodeCallback { + runProvider.project.logger.lifecycle("[Unimined/Auth] If your web browser does not open, please go to ${it.directVerificationUri}") + openUrl(it.directVerificationUri) + }) + } + + // write back to json + val creds = if (existingCredList != null) { + existingCredList.asJsonObject + } else { + JsonObject() + } + creds.add(session.mcProfile.name, MinecraftAuth.JAVA_DEVICE_CODE_LOGIN.toJson(session)) + daemonCache2 = creds + if (storeCredentials) { + if (encryptStoredCredentials) { + try { + val password = getOrSetPassword() + // encrypt auth.json + val encData = encryptAES256(creds.toString().toByteArray(), passwordToBytes(password)) + encAuthFile.writeBytes(encData) + } catch (e: Exception) { + runProvider.project.logger.error("[Unimined/Auth] Failed writing encrypted session data, no credentials will be stored!", e) + } + } else { + authFile.writeBytes(creds.toString().toByteArray()) + } + } + + AuthConfig.AuthInfo( + session.mcProfile.name, + session.mcProfile.id, + session.mcProfile.mcToken.accessToken + ) + }) + + private fun getOrSetPassword(): String { + Keyring.create().use { + runProvider.project.logger.info("[Unimined/Auth] Keyring Info: ${it.keyringStorageType}") + val existing = try { + it.getPassword("unimined", "authEncKey") + } catch (e: PasswordAccessException) { + runProvider.project.logger.error("[Unimined/Auth] error retrieving encryption password", e) + null + } + return if (existing != null) { + existing + } else { + val password = passwordGenerator() + it.setPassword("unimined", "authEncKey", password) + password + } + } + } + + fun deletePassword() { + Keyring.create().use { + it.deletePassword("unimined", "authEncKey") + } + } + + fun openUrl(url: String) { + // depending on platform, open url + when (OSUtils.oSId) { + OSUtils.WINDOWS -> { + Runtime.getRuntime().exec(arrayOf("rundll32", "url.dll,FileProtocolHandler", url)) + } + OSUtils.OSX -> { + Runtime.getRuntime().exec(arrayOf("open", url)) + } + OSUtils.LINUX -> { + Runtime.getRuntime().exec(arrayOf("xdg-open", url)) + } + } + } +} \ No newline at end of file diff --git a/testing/1.8.9-Forge-Fabric/gradle.properties b/testing/1.8.9-Forge-Fabric/gradle.properties index 44bd0530..9f9db6a4 100644 --- a/testing/1.8.9-Forge-Fabric/gradle.properties +++ b/testing/1.8.9-Forge-Fabric/gradle.properties @@ -2,4 +2,7 @@ org.gradle.jvmargs = -Xmx2G minecraft_version = 1.8.9 forge_version = 11.15.1.2318-1.8.9 -fabric_version = 0.14.20 \ No newline at end of file +fabric_version = 0.14.20 + +unimined.auth.enabled = true +unimined.auth.storeCredentials = true \ No newline at end of file