update readme, add ip address to log, close #35, WIP xbox auth

This commit is contained in:
creeper123123321 2020-12-27 12:01:21 -03:00
parent 2bdc547b66
commit a5c9abd574
6 changed files with 206 additions and 50 deletions

View File

@ -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

View File

@ -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))

View File

@ -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)

View File

@ -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 {

View File

@ -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")) {
"""<script src="https://hcaptcha.com/1/api.js"></script>
<form action="/xbox-auth/ms-login" method="POST" id="form">
<div class="h-captcha" data-sitekey="$siteKey" data-callback="hc"></div>
</form>
<script>function hc() { document.getElementById("form").submit(); }
window.onload = () => hcaptcha.execute();</script>
"""
}
}
val validTokens = Collections.newSetFromMap<UUID>(ConcurrentHashMap())
post("/xbox-auth/ms-login") {
val multipart = call.receiveParameters()
val hcaptchaResponse = httpClient.submitForm<JsonObject>(
"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<JsonObject>(
"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<JsonObject> {
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<JsonObject> {
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<JsonObject> {
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<WebSocketSession, WebClient>()
val loginTokens = CacheBuilder.newBuilder()
.expireAfterAccess(10, TimeUnit.DAYS)
.build<UUID, UUID>()
.expireAfterAccess(10, TimeUnit.DAYS)
.build<UUID, UUID>()
// Minecraft account -> WebClient
val listeners = ConcurrentHashMap<UUID, MutableSet<WebClient>>()
val usernameIdCache = CacheBuilder.newBuilder()
.expireAfterWrite(1, TimeUnit.HOURS)
.build<String, UUID>(CacheLoader.from { name ->
runBlocking {
withContext(Dispatchers.IO) {
httpClient.get<JsonObject?>("https://api.mojang.com/users/profiles/minecraft/$name")
?.get("id")?.asString?.let { fromUndashed(it) }
}
.expireAfterWrite(1, TimeUnit.HOURS)
.build<String, UUID>(CacheLoader.from { name ->
runBlocking {
withContext(Dispatchers.IO) {
httpClient.get<JsonObject?>("https://api.mojang.com/users/profiles/minecraft/$name")
?.get("id")?.asString?.let { fromUndashed(it) }
}
})
}
})
val pendingSessionHashes = CacheBuilder.newBuilder()
.expireAfterWrite(30, TimeUnit.SECONDS)
.build<String, CompletableFuture<Unit>>(CacheLoader.from { _ -> CompletableFuture() })
.expireAfterWrite(30, TimeUnit.SECONDS)
.build<String, CompletableFuture<Unit>>(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<Unit> {
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<UUID> = mutableSetOf())
data class WebClient(
val server: WebDashboardServer,
val ws: WebSocketServerSession,
val state: WebState,
val listenedIds: MutableSet<UUID> = 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<JsonObject>(
"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" -> {

View File

@ -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
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