diff --git a/README.md b/README.md index 1df2030..e35302d 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ Default WS URL: wss://localhost:25543/ws It requires a CORS Proxy for calling Mojang APIs, which may make Mojang see that as suspicious and reset/block your account password. +- There are some information about Mojang password resetting: + https://github.com/GeyserMC/Geyser/wiki/Common-Issues#mojang-resetting-account-credentials and + https://mobile.twitter.com/MojangSupport/status/863697596350517248 + - VIAaaS may have security vulnerabilities, make sure to block the ports in firewall and take care of browser local storage. Download: https://github.com/ViaVersion/VIAaaS/actions (needs to be logged into GitHub) @@ -21,18 +25,25 @@ Usage for offline mode: - Run the shadow jar or ./gradlew clean run - Connect to mc.example.com._v1_8.viaaas.localhost -Usage for online mode (may block your Mojang account): +Usage for online mode with two accounts (recommended): - Run the shadow jar or ./gradlew clean run -- It's recommended to use 2 premium accounts for online mode (using only one account is possible but, as only one access tokens - can be active, your Minecraft client will give Bad Login after you approve the login in your browser. You can use - https://www.curseforge.com/minecraft/mc-mods/auth-me for reauthenticate the client.) - You should set up a CORS Proxy (something like https://github.com/Rob--W/cors-anywhere) on local machine. -- Go to https://localhost:25543/auth.html, configure the CORS Proxy URL (something like http://localhost:8080/) and listen to - the username A that you're using to connect to the proxy. -- Add web page the account B you used in _u parameter. -- Connect to mc.example.com._v1_8.viaaas._u(account B).localhost +- Go to https://localhost:25543/auth.html, configure the CORS Proxy URL (something like http://localhost:8080/, + note the ending slash) and listen to the username A that you're using to connect to the proxy. +- Add the account B you'll use in _u parameter to browser auth page. +- Connect to mc.example.com._v1_8._u(account B).viaaas.localhost - Approve the login -- There are some information about Mojang password resetting: https://github.com/GeyserMC/Geyser/wiki/Common-Issues#mojang-resetting-account-credentials and https://mobile.twitter.com/MojangSupport/status/863697596350517248 + +Usage for online mode with one account: +- Run the shadow jar or ./gradlew clean run +- You should set up a CORS Proxy (something like https://github.com/Rob--W/cors-anywhere) on local machine. +- Go to https://localhost:25543/auth.html, configure the CORS Proxy URL (something like http://localhost:8080/, + note the ending slash) and listen to the username. +- Add the account to browser auth page. +- Connect to mc.example.com._v1_8.viaaas.localhost +- Approve the login +- Minecraft client will give Bad Login after you approve the login in your browser. You can use + https://www.curseforge.com/minecraft/mc-mods/auth-me for reauthenticate the client. ## WARNING VIAaaS may trigger anti-cheats, due to block, item, movement and other differences between versions. USE AT OWN RISK diff --git a/src/main/kotlin/com/github/creeper123123321/viaaas/CloudHandler.kt b/src/main/kotlin/com/github/creeper123123321/viaaas/CloudHandler.kt index 17ed7fc..0d77cd8 100644 --- a/src/main/kotlin/com/github/creeper123123321/viaaas/CloudHandler.kt +++ b/src/main/kotlin/com/github/creeper123123321/viaaas/CloudHandler.kt @@ -131,6 +131,10 @@ class HandshakeState : MinecraftConnectionState { else -> throw IllegalStateException("Invalid next state") } + if (!handler.user.get(CloudData::class.java)!!.hadHostname && VIAaaSConfig.requireHostName) { + throw UnsupportedOperationException("This VIAaaS instance requires you to use the hostname") + } + handler.user.channel!!.setAutoRead(false) GlobalScope.launch(Dispatchers.IO) { val frontHandler = handler.user.channel!!.pipeline().get(CloudMinecraftHandler::class.java) @@ -153,9 +157,9 @@ class HandshakeState : MinecraftConnectionState { val socketAddr = InetSocketAddress(InetAddress.getByName(srvResolvedAddr), srvResolvedPort) val addrInfo = socketAddr.address if (VIAaaSConfig.blockLocalAddress && (addrInfo.isSiteLocalAddress - || addrInfo.isLoopbackAddress - || addrInfo.isLinkLocalAddress - || addrInfo.isAnyLocalAddress) + || addrInfo.isLoopbackAddress + || addrInfo.isLinkLocalAddress + || addrInfo.isAnyLocalAddress) ) throw SecurityException("Local addresses aren't allowed") val bootstrap = Bootstrap().handler(BackendInit(handler.user)) diff --git a/src/main/kotlin/com/github/creeper123123321/viaaas/CloudProtocol.kt b/src/main/kotlin/com/github/creeper123123321/viaaas/CloudProtocol.kt index fc19c7e..92fd3c5 100644 --- a/src/main/kotlin/com/github/creeper123123321/viaaas/CloudProtocol.kt +++ b/src/main/kotlin/com/github/creeper123123321/viaaas/CloudProtocol.kt @@ -35,8 +35,11 @@ object CloudHeadProtocol : SimpleProtocol() { val receivedPort = wrapper.read(Type.UNSIGNED_SHORT) val parsed = VIAaaSAddress().parse(addr.substringBefore(0.toChar()), VIAaaSConfig.hostName) - if (parsed.viaSuffix == null && VIAaaSConfig.requireHostName) throw IllegalStateException("VIAaaS hostname is required") - val backPort = parsed.port ?: receivedPort + val backPort = parsed.port ?: if (VIAaaSConfig.defaultBackendPort == -1) { + receivedPort + } else { + VIAaaSConfig.defaultBackendPort + } val backAddr = parsed.realAddress val backProto = parsed.protocol ?: 47 @@ -52,7 +55,8 @@ object CloudHeadProtocol : SimpleProtocol() { userConnection = wrapper.user(), backendVer = backProto, frontOnline = parsed.online, - altName = parsed.altUsername + altName = parsed.altUsername, + hadHostname = parsed.viaSuffix == null ) ) @@ -67,5 +71,6 @@ data class CloudData( val userConnection: UserConnection, var backendVer: Int, var frontOnline: Boolean, - var altName: String? + var altName: String?, + var hadHostname: Boolean ) : StoredObject(userConnection) \ No newline at end of file diff --git a/src/main/kotlin/com/github/creeper123123321/viaaas/VIAaaS.kt b/src/main/kotlin/com/github/creeper123123321/viaaas/VIAaaS.kt index a4df97c..0906296 100644 --- a/src/main/kotlin/com/github/creeper123123321/viaaas/VIAaaS.kt +++ b/src/main/kotlin/com/github/creeper123123321/viaaas/VIAaaS.kt @@ -256,6 +256,7 @@ object VIAaaSConfig : Config(File("config/viaaas.yml")) { val useStrongRandom: Boolean get() = this.getBoolean("use-strong-random", true) val blockLocalAddress: Boolean get() = this.getBoolean("block-local-address", true) val requireHostName: Boolean get() = this.getBoolean("require-host-name", true) + val defaultBackendPort: Int get() = this.getInt("default-backend-port", 25565) } class VIAaaSAddress { diff --git a/src/main/kotlin/com/github/creeper123123321/viaaas/ViaWeb.kt b/src/main/kotlin/com/github/creeper123123321/viaaas/ViaWeb.kt index 330d244..079b5b3 100644 --- a/src/main/kotlin/com/github/creeper123123321/viaaas/ViaWeb.kt +++ b/src/main/kotlin/com/github/creeper123123321/viaaas/ViaWeb.kt @@ -4,15 +4,20 @@ import com.google.common.base.Preconditions import com.google.common.cache.CacheBuilder import com.google.common.cache.CacheLoader import com.google.gson.Gson +import com.google.gson.JsonArray import com.google.gson.JsonObject import io.ktor.application.* +import io.ktor.client.features.json.* import io.ktor.client.request.* import io.ktor.client.request.forms.* import io.ktor.features.* import io.ktor.http.* import io.ktor.http.cio.websocket.* import io.ktor.http.content.* +import io.ktor.request.* +import io.ktor.response.* import io.ktor.routing.* +import io.ktor.util.* import io.ktor.websocket.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.consumeEach @@ -67,6 +72,126 @@ class ViaWebApp { } } + // todo xbox auth + /* + val redirectUrl = "https://localhost:25543/xbox-auth/ms-callback" + val siteKey = "9e95fd56-0f45-42f9-af28-9b803645da22" + val secretKey = "redacted" + val azureClientId = "a370fff9-7648-4dbf-b96e-2b4f8d539ac2" + val azureClientSecret = "redacted" + + get("/xbox-auth/") { + call.respondText(contentType = ContentType.parse("text/html")) { + """ +
+
+
+ +""" + } + } + + val validTokens = Collections.newSetFromMap(ConcurrentHashMap()) + + post("/xbox-auth/ms-login") { + val multipart = call.receiveParameters() + + val hcaptchaResponse = httpClient.submitForm( + "https://hcaptcha.com/siteverify", + parametersOf( + "response" to listOf(multipart["h-captcha-response"]!!), + "secret" to listOf(secretKey), + "siteKey" to listOf(siteKey) + ) + ) + + if (!hcaptchaResponse.get("success").asBoolean) { + call.respondText(status = HttpStatusCode.Forbidden) { "hcaptcha failed" } + return@post + } + + call.respondRedirect(permanent = false) { + takeFrom( + "https://login.live.com/oauth20_authorize.srf" + + "?client_id=$azureClientId" + + "&response_type=code" + + "&redirect_uri=${URLEncoder.encode(redirectUrl, Charsets.UTF_8)}" + + "&scope=XboxLive.signin" + + "&state=${UUID.randomUUID().also { validTokens.add(it) }}" + ) + } + } + + get("/xbox-auth/ms-callback") { + val authCode = call.request.queryParameters.getOrFail("code") + val state = call.request.queryParameters.getOrFail("state") + + if (!validTokens.remove(UUID.fromString(state))) { + call.respondText(status = HttpStatusCode.Forbidden) { "failed state token" } + return@get + } + val authToken = httpClient.submitForm( + "https://login.live.com/oauth20_token.srf", + parametersOf( + "client_id" to listOf(azureClientId), + "client_secret" to listOf(azureClientSecret), + "code" to listOf(authCode), + "grant_type" to listOf("authorization_code"), + "redirect_uri" to listOf(redirectUrl) + ) + ) + + + val xboxLiveAuthResult = httpClient.post { + url("https://user.auth.xboxlive.com/user/authenticate") + body = JsonObject().also { + it.add("Properties", JsonObject().also { + it.addProperty("AuthMethod", "RPS") + it.addProperty("SiteName", "user.auth.xboxlive.com") + it.addProperty("RpsTicket", authToken.get("access_token").asString) + }) + it.addProperty("TokenType", "JWT") + it.addProperty("RelyingParty", "http://auth.xboxlive.com") + } + header("content-type", "application/json") + header("accept", "application/json") + } + + val xstsAuth = httpClient.post { + url("https://xsts.auth.xboxlive.com/xsts/authorize") + body = JsonObject().also { + it.add("Properties", JsonObject().also { + it.addProperty("SandboxId", "RETAIL") + it.add( + "UserTokens", + JsonArray().also { it.add(xboxLiveAuthResult.get("Token").asString) }) + }) + it.addProperty("TokenType", "JWT") + it.addProperty("RelyingParty", "rp://api.minecraftservices.com/") + } + header("content-type", "application/json") + header("accept", "application/json") + } + + val mcToken = httpClient.post { + url("https://api.minecraftservices.com/authentication/login_with_xbox") + body = JsonObject().also { + it.addProperty( + "identityToken", + "XBL3.0 x=${ + xstsAuth.getAsJsonObject("DisplayClaims").getAsJsonArray("xui") + .first { it.asJsonObject.has("uhs") }.asJsonObject.get("uhs").asString + };${xstsAuth.get("Token").asString}" + ) + } + header("content-type", "application/json") + header("accept", "application/json") + } + + call.respondText { mcToken.get("access_token").asString } + } */ + static { defaultResource("index.html", "web") resources("web") @@ -79,43 +204,47 @@ class ViaWebApp { fun fromUndashed(string: String): UUID { Preconditions.checkArgument(string.length == 32, "Length is incorrect") return UUID( - java.lang.Long.parseUnsignedLong(string.substring(0, 16), 16), - java.lang.Long.parseUnsignedLong(string.substring(16), 16) + java.lang.Long.parseUnsignedLong(string.substring(0, 16), 16), + java.lang.Long.parseUnsignedLong(string.substring(16), 16) ) } class WebDashboardServer { val clients = ConcurrentHashMap() val loginTokens = CacheBuilder.newBuilder() - .expireAfterAccess(10, TimeUnit.DAYS) - .build() + .expireAfterAccess(10, TimeUnit.DAYS) + .build() // Minecraft account -> WebClient val listeners = ConcurrentHashMap>() val usernameIdCache = CacheBuilder.newBuilder() - .expireAfterWrite(1, TimeUnit.HOURS) - .build(CacheLoader.from { name -> - runBlocking { - withContext(Dispatchers.IO) { - httpClient.get("https://api.mojang.com/users/profiles/minecraft/$name") - ?.get("id")?.asString?.let { fromUndashed(it) } - } + .expireAfterWrite(1, TimeUnit.HOURS) + .build(CacheLoader.from { name -> + runBlocking { + withContext(Dispatchers.IO) { + httpClient.get("https://api.mojang.com/users/profiles/minecraft/$name") + ?.get("id")?.asString?.let { fromUndashed(it) } } - }) + } + }) val pendingSessionHashes = CacheBuilder.newBuilder() - .expireAfterWrite(30, TimeUnit.SECONDS) - .build>(CacheLoader.from { _ -> CompletableFuture() }) + .expireAfterWrite(30, TimeUnit.SECONDS) + .build>(CacheLoader.from { _ -> CompletableFuture() }) - suspend fun requestSessionJoin(id: UUID, name: String, hash: String, - address: SocketAddress, backKey: PublicKey) + suspend fun requestSessionJoin( + id: UUID, name: String, hash: String, + address: SocketAddress, backKey: PublicKey + ) : CompletableFuture { val future = viaWebServer.pendingSessionHashes.get(hash) var sent = 0 viaWebServer.listeners[id]?.forEach { - it.ws.send("""{"action": "session_hash_request", "user": "$name", "session_hash": "$hash", + it.ws.send( + """{"action": "session_hash_request", "user": "$name", "session_hash": "$hash", | "client_address": "$address", "backend_public_key": - | "${Base64.getEncoder().encodeToString(backKey.encoded)}"}""".trimMargin()) + | "${Base64.getEncoder().encodeToString(backKey.encoded)}"}""".trimMargin() + ) it.ws.flush() sent++ } @@ -154,10 +283,12 @@ class WebDashboardServer { } -data class WebClient(val server: WebDashboardServer, - val ws: WebSocketServerSession, - val state: WebState, - val listenedIds: MutableSet = mutableSetOf()) +data class WebClient( + val server: WebDashboardServer, + val ws: WebSocketServerSession, + val state: WebState, + val listenedIds: MutableSet = mutableSetOf() +) interface WebState { suspend fun start(webClient: WebClient) @@ -181,10 +312,9 @@ class WebLogin : WebState { val code = obj.getAsJsonPrimitive("code").asString val check = httpClient.submitForm( - "https://api.minecraft.id/gateway/verify/${URLEncoder.encode(username, Charsets.UTF_8)}", - formParameters = parametersOf("code", code), - encodeInQuery = false) { - } + "https://api.minecraft.id/gateway/verify/${URLEncoder.encode(username, Charsets.UTF_8)}", + formParameters = parametersOf("code", code), + ) if (check.getAsJsonPrimitive("valid").asBoolean) { val token = UUID.randomUUID() @@ -192,13 +322,15 @@ class WebLogin : WebState { val uuid = webClient.server.usernameIdCache.get(mcIdUser) webClient.server.loginTokens.put(token, uuid) - webClient.ws.send("""{"action": "minecraft_id_result", "success": true, - | "username": "$mcIdUser", "uuid": "$uuid", "token": "$token"}""".trimMargin()) + webClient.ws.send( + """{"action": "minecraft_id_result", "success": true, + | "username": "$mcIdUser", "uuid": "$uuid", "token": "$token"}""".trimMargin() + ) - webLogger.info("Generated a token for account $mcIdUser $uuid") + webLogger.info("${webClient.ws.call.request.local.remoteHost} (O: ${webClient.ws.call.request.origin.remoteHost}) generated a token for account $mcIdUser $uuid") } else { webClient.ws.send("""{"action": "minecraft_id_result", "success": false}""") - webLogger.info("Failed to generated a token for account $username") + webLogger.info("${webClient.ws.call.request.local.remoteHost} (O: ${webClient.ws.call.request.origin.remoteHost}) failed to generated a token for account $username") } } "listen_login_requests" -> { @@ -208,12 +340,12 @@ class WebLogin : WebState { webClient.ws.send("""{"action": "listen_login_requests_result", "token": "$token", "success": true, "user": "$user"}""") webClient.listenedIds.add(user) webClient.server.listeners.computeIfAbsent(user) { Collections.newSetFromMap(ConcurrentHashMap()) } - .add(webClient) + .add(webClient) - webLogger.info("Listening for logins for $user") + webLogger.info("${webClient.ws.call.request.local.remoteHost} (O: ${webClient.ws.call.request.origin.remoteHost}) listening for logins for $user") } else { webClient.ws.send("""{"action": "listen_login_requests_result", "token": "$token", "success": false}""") - webLogger.info("Failed token") + webLogger.info("${webClient.ws.call.request.local.remoteHost} (O: ${webClient.ws.call.request.origin.remoteHost}) failed token") } } "session_hash_response" -> { diff --git a/src/main/resources/viaaas.yml b/src/main/resources/viaaas.yml index bab8976..0d6d733 100644 --- a/src/main/resources/viaaas.yml +++ b/src/main/resources/viaaas.yml @@ -18,4 +18,7 @@ use-strong-random: true block-local-address: true # Requires virtual host to contain the value from "host-name" # A false value could be used for transparent proxying. -require-host-name: true \ No newline at end of file +require-host-name: true +# Default port to be used to connect to backend server +# Use -1 to reuse the port sent by client, useful for transparent proxying +default-backend-port: 25565 \ No newline at end of file