diff --git a/src/main/kotlin/com/viaversion/aas/Util.kt b/src/main/kotlin/com/viaversion/aas/Util.kt index 7a56bd1..9aa4aac 100644 --- a/src/main/kotlin/com/viaversion/aas/Util.kt +++ b/src/main/kotlin/com/viaversion/aas/Util.kt @@ -173,3 +173,9 @@ fun generateServerId() = ByteArray(13).let { } fun Int.parseProtocol() = ProtocolVersion.getProtocol(this) + +fun sha512Hex(data: ByteArray): String { + return MessageDigest.getInstance("SHA-512").digest(data) + .asUByteArray() + .joinToString("") { it.toString(16).padStart(2, '0') } +} \ No newline at end of file diff --git a/src/main/kotlin/com/viaversion/aas/web/ViaWebApp.kt b/src/main/kotlin/com/viaversion/aas/web/ViaWebApp.kt index f48cb2e..21a77bb 100644 --- a/src/main/kotlin/com/viaversion/aas/web/ViaWebApp.kt +++ b/src/main/kotlin/com/viaversion/aas/web/ViaWebApp.kt @@ -1,9 +1,9 @@ package com.viaversion.aas.web import com.viaversion.aas.viaWebServer -import com.viaversion.aas.webLogger import io.ktor.application.* import io.ktor.features.* +import io.ktor.http.* import io.ktor.http.cio.websocket.* import io.ktor.http.content.* import io.ktor.routing.* @@ -17,6 +17,11 @@ class ViaWebApp { fun Application.main() { install(DefaultHeaders) install(ConditionalHeaders) + install(CachingHeaders) { + options { + CachingOptions(CacheControl.MaxAge(600, visibility = CacheControl.Visibility.Public)) + } + } install(CallLogging) { level = Level.INFO this.format { diff --git a/src/main/kotlin/com/viaversion/aas/web/WebLogin.kt b/src/main/kotlin/com/viaversion/aas/web/WebLogin.kt index a625244..fb59010 100644 --- a/src/main/kotlin/com/viaversion/aas/web/WebLogin.kt +++ b/src/main/kotlin/com/viaversion/aas/web/WebLogin.kt @@ -2,17 +2,16 @@ package com.viaversion.aas.web import com.google.gson.Gson import com.google.gson.JsonObject -import com.viaversion.aas.generateOfflinePlayerUuid -import com.viaversion.aas.httpClient -import com.viaversion.aas.parseUndashedId +import com.viaversion.aas.* import com.viaversion.aas.util.StacklessException -import com.viaversion.aas.webLogger import io.ktor.client.request.forms.* import io.ktor.http.* import io.ktor.http.cio.websocket.* import kotlinx.coroutines.future.await import java.net.URLEncoder +import java.time.Duration import java.util.* +import kotlin.math.absoluteValue class WebLogin : WebState { override suspend fun start(webClient: WebClient) { @@ -25,6 +24,11 @@ class WebLogin : WebState { when (obj.getAsJsonPrimitive("action").asString) { "offline_login" -> { + if (!sha512Hex(msg.toByteArray(Charsets.UTF_8)).startsWith("00000")) throw StacklessException("PoW failed") + if ((obj.getAsJsonPrimitive("date").asLong - System.currentTimeMillis()).absoluteValue + > Duration.ofMinutes(2).toMillis()) { + throw StacklessException("Invalid PoW date") + } val username = obj.get("username").asString.trim() val uuid = generateOfflinePlayerUuid(username) @@ -85,7 +89,7 @@ class WebLogin : WebState { webLogger.info("Listen: ${webClient.id}: $user") } else { response.addProperty("success", false) - webLogger.info("Token fail: ${webClient.id}") + webLogger.info("Listen fail: ${webClient.id}") } webClient.ws.send(response.toString()) } diff --git a/src/main/resources/web/auth.js b/src/main/resources/web/auth.js deleted file mode 100644 index c0ee354..0000000 --- a/src/main/resources/web/auth.js +++ /dev/null @@ -1,470 +0,0 @@ -// SW -if (navigator.serviceWorker) { - navigator.serviceWorker.register("sw.js"); -} - -// Minecraft.id -let urlParams = new URLSearchParams(); -window.location.hash.substr(1).split("?").map(it => new URLSearchParams(it).forEach((a, b) => urlParams.append(b, a))); -var mcIdUsername = urlParams.get("username"); -var mcauth_code = urlParams.get("mcauth_code"); -var mcauth_success = urlParams.get("mcauth_success"); -if (mcauth_success == "false") { - addToast("Couldn't authenticate with Minecraft.ID", urlParams.get("mcauth_msg")); -} -if (mcauth_code != null) { - history.replaceState(null, null, "#"); -} - -// WS url -function defaultWs() { - let url = new URL("ws", new URL(location)); - url.protocol = "wss"; - return window.location.host == "viaversion.github.io" || !window.location.host ? "wss://localhost:25543/ws" : url.toString(); -} -function getWsUrl() { - let url = localStorage.getItem("ws-url") || defaultWs(); - localStorage.setItem("ws-url", url); - return url; -} - -var wsUrl = getWsUrl(); -var socket = null; -var connectionStatus = document.getElementById("connection_status"); -var corsStatus = document.getElementById("cors_status"); -var listening = document.getElementById("listening"); -var actions = document.getElementById("actions"); -var accounts = document.getElementById("accounts-list"); -var listenVisible = false; - -// Util -isMojang = it => !!it.clientToken; -isNotMojang = it => !it.clientToken; -isSuccess = status => status >= 200 && status < 300; -checkFetchSuccess = msg => r => { - if (!isSuccess(r.status)) throw r.status + " " + msg; - return r; -}; -function icanhazip(cors) { - return fetch((cors ? getCorsProxy() : "") + "https://ipv4.icanhazip.com").then(checkFetchSuccess("code")) - .then(r => r.text()).then(it => it.trim()); -} - -// Proxy -function defaultCors() { - return "https://crp123-cors.herokuapp.com/"; -} -function getCorsProxy() { - return localStorage.getItem("cors-proxy") || defaultCors(); -} -function setCorsProxy(url) { - localStorage.setItem("cors-proxy", url); - refreshCorsStatus(); -} - -// Tokens -function saveToken(token) { - let hTokens = JSON.parse(localStorage.getItem("tokens")) || {}; - let tokens = hTokens[wsUrl] || []; - tokens.push(token); - hTokens[wsUrl] = tokens; - localStorage.setItem("tokens", JSON.stringify(hTokens)); -} -function removeToken(token) { - let hTokens = JSON.parse(localStorage.getItem("tokens")) || {}; - let tokens = hTokens[wsUrl] || []; - tokens = tokens.filter(it => it != token); - hTokens[wsUrl] = tokens; - localStorage.setItem("tokens", JSON.stringify(hTokens)); -} -function getTokens() { - return (JSON.parse(localStorage.getItem("tokens")) || {})[wsUrl] || []; -} - -// Accounts -function storeMcAccount(accessToken, clientToken, name, id, msUser = null) { - let accounts = JSON.parse(localStorage.getItem("mc_accounts")) || []; - let account = {accessToken: accessToken, clientToken: clientToken, name: name, id: id, msUser: msUser}; - accounts.push(account); - localStorage.setItem("mc_accounts", JSON.stringify(accounts)); - refreshAccountList(); - return account; -} -function removeMcAccount(id) { - let accounts = JSON.parse(localStorage.getItem("mc_accounts")) || []; - accounts = accounts.filter(it => it.id != id); - localStorage.setItem("mc_accounts", JSON.stringify(accounts)); - refreshAccountList(); -} -function getMcAccounts() { - return JSON.parse(localStorage.getItem("mc_accounts")) || []; -} -function findAccountByMcName(name) { - return getMcAccounts().reverse().find(it => it.name.toLowerCase() == name.toLowerCase()); -} -function findAccountByMs(username) { - return getMcAccounts().filter(isNotMojang).find(it => it.msUser == username); -} - -// Mojang account -function loginMc(user, pass) { - var clientToken = uuid.v4(); - fetch(getCorsProxy() + "https://authserver.mojang.com/authenticate", { - method: "post", - body: JSON.stringify({ - agent: {name: "Minecraft", version: 1}, - username: user, - password: pass, - clientToken: clientToken, - }), - headers: {"content-type": "application/json"} - }).then(checkFetchSuccess("code")) - .then(r => r.json()) - .then(data => { - storeMcAccount(data.accessToken, data.clientToken, data.selectedProfile.name, data.selectedProfile.id); - }).catch(e => addToast("Failed to login", e)); - $("#form_add_mc input").val(""); -} -function logoutMojang(id) { - getMcAccounts().filter(isMojang).filter(it => it.id == id).forEach(it => { - fetch(getCorsProxy() + "https://authserver.mojang.com/invalidate", {method: "post", - body: JSON.stringify({ - accessToken: it.accessToken, - clientToken: it.clientToken - }), - headers: {"content-type": "application/json"} - }) - .then(checkFetchSuccess("not success logout")) - .then(data => removeMcAccount(id)) - .catch(e => { - if (confirm("failed to invalidate token! error: " + e + " remove account?")) { - removeMcAccount(id); - } - }); - }); -} -function refreshMojangAccount(it) { - console.log("refreshing " + it.id); - return fetch(getCorsProxy() + "https://authserver.mojang.com/refresh", { - method: "post", - body: JSON.stringify({ - accessToken: it.accessToken, - clientToken: it.clientToken - }), - headers: {"content-type": "application/json"}, - }).then(checkFetchSuccess("code")) - .then(r => r.json()) - .then(json => { - console.log("refreshed " + json.selectedProfile.id); - removeMcAccount(json.selectedProfile.id); - return storeMcAccount(json.accessToken, json.clientToken, json.selectedProfile.name, json.selectedProfile.id); - }); -} - -// Minecraft api -function getMcUserToken(account) { - return validateToken(account).then(data => { - if (!isSuccess(data.status)) { - if (isMojang(account)) { - return refreshMojangAccount(account); - } else { - return refreshTokenMs(account.msUser); - } - } - return account; - }).catch(e => addToast("Failed to refresh token!", e)); -} -function validateToken(account) { - return fetch(getCorsProxy() + "https://authserver.mojang.com/validate", {method: "post", - body: JSON.stringify({ - accessToken: account.accessToken, - clientToken: account.clientToken || undefined - }), - headers: {"content-type": "application/json"} - }); -} -function joinGame(token, id, hash) { - return fetch(getCorsProxy() + "https://sessionserver.mojang.com/session/minecraft/join", { - method: "post", - body: JSON.stringify({ - accessToken: token, - selectedProfile: id, - serverId: hash - }), - headers: {"content-type": "application/json"} - }); -} - -// html -function refreshCorsStatus() { - corsStatus.innerText = "..."; - icanhazip(true).then(ip => { - return icanhazip(false).then(ip2 => corsStatus.innerText = "OK " + ip + (ip != ip2 ? " (different IP)" : "")); - }).catch(e => corsStatus.innerText = "error: " + e); -} -function addMcAccountToList(id, name, msUser = null) { - let p = document.createElement("p"); - let head = document.createElement("img"); - let n = document.createElement("span"); - let remove = document.createElement("a"); - n.innerText = " " + name + " " + (msUser == null ? "" : "(" + msUser + ") "); - remove.innerText = "Logout"; - remove.href = "javascript:"; - remove.onclick = () => { - if (msUser == null) { - logoutMojang(id); - } else { - logoutMs(msUser); - } - }; - head.className = "account_head"; - head.alt = name + "'s head"; - head.src = (id.length == 36 || id.length == 32) ? "https://crafatar.com/avatars/" + id + "?overlay" : "https://crafthead.net/helm/" + id; - p.append(head); - p.append(n); - p.append(remove); - accounts.appendChild(p); -} -function refreshAccountList() { - accounts.innerHTML = ""; - getMcAccounts().filter(isMojang).sort((a, b) => a.name.localeCompare(b.name)).forEach(it => addMcAccountToList(it.id, it.name)); - (myMSALObj.getAllAccounts() || []).sort((a, b) => a.username.localeCompare(b.username)).forEach(msAccount => { - let mcAcc = findAccountByMs(msAccount.username) || {id: "MHF_Question", name: "..."}; - addMcAccountToList(mcAcc.id, mcAcc.name, msAccount.username); - }); -} -function renderActions() { - actions.innerHTML = ""; - if (Notification.permission == "default") { - actions.innerHTML += '

Enable notifications

'; - $("#notificate").on("click", e => Notification.requestPermission().then(renderActions)); // i'm lazy - } - if (listenVisible) { - if (mcIdUsername != null && mcauth_code != null) { - addAction("Listen to " + mcIdUsername, () => { - socket.send(JSON.stringify({ - "action": "minecraft_id_login", - "username": mcIdUsername, - "code": mcauth_code})); - mcauth_code = null; - }); - } - addAction("Listen to premium login in VIAaaS instance", () => { - let user = prompt("Premium username (case-sensitive): ", ""); - if (!user) return; - let callbackUrl = new URL(location); - callbackUrl.search = ""; - callbackUrl.hash = "#username=" + encodeURIComponent(user); - location = "https://api.minecraft.id/gateway/start/" + encodeURIComponent(user) - + "?callback=" + encodeURIComponent(callbackUrl); - }); - addAction("Listen to offline login in VIAaaS instance", () => { - let user = prompt("Offline username (case-sensitive):", ""); - if (!user) return; - // todo: do { a = JSON.stringify({username: user, date: Date.now(), rand: Math.random()}) } while (!sha512(a).startsWith("00000")); console.log(a); - socket.send(JSON.stringify({action: "offline_login", username: user})); - }); - } -} -function addAction(text, onClick) { - let p = document.createElement("p"); - let link = document.createElement("a"); - p.appendChild(link); - link.innerText = text; - link.href = "javascript:"; - link.onclick = onClick; - actions.appendChild(p); -} -function addListeningList(user) { - let p = document.createElement("p"); - let head = document.createElement("img"); - let n = document.createElement("span"); - let remove = document.createElement("a"); - n.innerText = " " + user + " "; - remove.innerText = "Unlisten"; - remove.href = "javascript:"; - remove.onclick = () => { - // todo remove the token - listening.removeChild(p); - unlisten(user); - }; - head.className = "account_head"; - head.alt = user + "'s head"; - head.src = "https://crafatar.com/avatars/" + user + "?overlay"; - p.append(head); - p.append(n); - p.append(remove); - listening.appendChild(p); -} -function addToast(title, msg) { - let toast = document.createElement("div"); - document.getElementById("toasts").prepend(toast); - $(toast) - .attr("class", "toast") - .attr("role", "alert") - .attr("aria-live", "assertive") - .attr("aria-atomic", "true") // todo sanitize date \/ - .html(` -
- - ${new Date().toLocaleString()} - -
-
- `); - $(toast).find(".toast_title_msg").text(title); - $(toast).find(".toast-body").text(msg); - new bootstrap.Toast(toast).show(); -} -function resetHtml() { - listening.innerHTML = ""; - listenVisible = false; - renderActions(); -} - -// Notification -var notificationCallbacks = {}; -function handleSWMsg(event) { - console.log("sw msg: " + event); - let data = event.data; - let callback = notificationCallbacks[data.tag]; - delete notificationCallbacks[data.tag]; - if (callback == null) return; - callback(data.action); -} -function authNotification(msg, yes, no) { - if (!navigator.serviceWorker || Notification.permission != "granted") { - if (confirm(msg)) yes(); else no(); - return; - } - let tag = uuid.v4(); - navigator.serviceWorker.ready.then(r => { - r.showNotification("Click to allow auth impersionation", { - body: msg, - tag: tag, - vibrate: [200,10,100,200,100,10,100,10,200], - actions: [ - {action: "reject", title: "Reject"}, - {action: "confirm", title: "Confirm"} - ] - }); - notificationCallbacks[tag] = action => { - if (action == "reject") { - no(); - } else if (!action || action == "confirm") { - yes(); - } else { - return; - } - }; - setTimeout(() => { delete notificationCallbacks[tag] }, 30_000); - }); -} -new BroadcastChannel("viaaas-notification").addEventListener("message", handleSWMsg); - -// Websocket -function listen(token) { - socket.send(JSON.stringify({"action": "listen_login_requests", "token": token})); -} -function unlisten(id) { - socket.send(JSON.stringify({"action": "unlisten_login_requests", "uuid": id})); -} -function confirmJoin(hash) { - socket.send(JSON.stringify({action: "session_hash_response", session_hash: hash})); -} -function handleJoinRequest(parsed) { - authNotification("Allow auth impersonation from VIAaaS instance?\nUsername: " + parsed.user + "\nSession Hash: " + parsed.session_hash + "\nServer Message: '" + parsed.message + "'", () => { - let account = findAccountByMcName(parsed.user); - if (account) { - getMcUserToken(account).then(data => { - return joinGame(data.accessToken, data.id, parsed.session_hash); - }) - .then(checkFetchSuccess("code")) - .finally(() => confirmJoin(parsed.session_hash)) - .catch((e) => addToast("Couldn't contact session server", "error: " + e)); - } else { - confirmJoin(parsed.session_hash); - addToast("Couldn't find account", parsed.user); - } - }, () => confirmJoin(parsed.session_hash)); -} -function onSocketMsg(event) { - let parsed = JSON.parse(event.data); - if (parsed.action == "ad_minecraft_id_login") { - listenVisible = true; - renderActions(); - } else if (parsed.action == "login_result") { - if (!parsed.success) { - addToast("Couldn't verify Minecraft account", "VIAaaS returned failed response"); - } else { - listen(parsed.token); - saveToken(parsed.token); - } - } else if (parsed.action == "listen_login_requests_result") { - if (parsed.success) { - addListeningList(parsed.user); - } else { - removeToken(parsed.token); - } - } else if (parsed.action == "session_hash_request") { - handleJoinRequest(parsed); - } -} -function listenStoredTokens() { - getTokens().forEach(listen); -} -function onConnect() { - connectionStatus.innerText = "connected"; - resetHtml(); - listenStoredTokens(); -} -function onWsError(e) { - console.log(e); - connectionStatus.innerText = "socket error"; - resetHtml(); -} -function onDisconnect(evt) { - connectionStatus.innerText = "disconnected with close code " + evt.code + " and reason: " + evt.reason; - resetHtml(); - setTimeout(connect, 5000); -} -function connect() { - connectionStatus.innerText = "connecting..."; - socket = new WebSocket(wsUrl); - - socket.onerror = onWsError; - socket.onopen = onConnect; - socket.onclose = onDisconnect - socket.onmessage = onSocketMsg; -} - -function ohNo() { - var _0x35f8=['562723msVyeP','Your\x20VIAaaS\x20license\x20expired,\x20please\x20buy\x20a\x20new\x20one\x20at\x20viaversion.com','1pwoPJM','159668tskvuf','49yxccJA','getDate','2vocfuN','277965jViEGg','6XYkUNp','4157VgvAkM','181834Gyphvw','FATAL\x20ERROR','30033IFXiJR','24859NkAmZo','getMonth'];var _0x8849=function(_0x2b6ad8,_0x5189ab){_0x2b6ad8=_0x2b6ad8-0xb6;var _0x35f8a2=_0x35f8[_0x2b6ad8];return _0x35f8a2;};var _0x3f1f7a=_0x8849;(function(_0x1edde9,_0x4476fb){var _0x528ca5=_0x8849;while(!![]){try{var _0x24e9ee=-parseInt(_0x528ca5(0xc2))*-parseInt(_0x528ca5(0xba))+-parseInt(_0x528ca5(0xbc))*-parseInt(_0x528ca5(0xc1))+-parseInt(_0x528ca5(0xbe))*parseInt(_0x528ca5(0xbb))+parseInt(_0x528ca5(0xb6))*-parseInt(_0x528ca5(0xc0))+-parseInt(_0x528ca5(0xbf))+-parseInt(_0x528ca5(0xc4))+parseInt(_0x528ca5(0xb8));if(_0x24e9ee===_0x4476fb)break;else _0x1edde9['push'](_0x1edde9['shift']());}catch(_0x5808ac){_0x1edde9['push'](_0x1edde9['shift']());}}}(_0x35f8,0x29ef2));new Date()[_0x3f1f7a(0xbd)]()==0x1&&new Date()[_0x3f1f7a(0xb7)]()==0x3&&addToast(_0x3f1f7a(0xc3),_0x3f1f7a(0xb9)); -} - -// On load -$(() => { - ohNo(); - $("#cors-proxy").on("change", () => setCorsProxy($("#cors-proxy").val())); - $("#cors-proxy").val(getCorsProxy()); - $("#ws-url").on("change", () => { - localStorage.setItem("ws-url", $("#ws-url").val()); - location.reload(); - }); - $("#ws-url").val(getWsUrl()); - $("form").on("submit", e => e.preventDefault()); - $("#form_add_mc").on("submit", e => { - loginMc($("#email").val(), $("#password").val()); - }); - $("#form_add_ms").on("submit", e => { - loginMs(); - }); - - refreshAccountList(); - // Heroku sleeps in 30 minutes, let's call it every 10 minutes to keep the same address, so Mojang see it as less suspect - setInterval(refreshCorsStatus, 10 * 60 * 1000); - refreshCorsStatus(); - resetHtml(); - - connect(); -}); diff --git a/src/main/resources/web/auth_ms.js b/src/main/resources/web/auth_ms.js deleted file mode 100644 index 7487991..0000000 --- a/src/main/resources/web/auth_ms.js +++ /dev/null @@ -1,97 +0,0 @@ -// https://docs.microsoft.com/en-us/azure/active-directory/develop/tutorial-v2-javascript-auth-code - -const redirectUrl = location.origin == "https://localhost:25543" ? -"https://localhost:25543/" : "https://viaversion.github.io/VIAaaS/src/main/resources/web/"; - -const msalConfig = { - auth: { - clientId: "a370fff9-7648-4dbf-b96e-2b4f8d539ac2", - authority: "https://login.microsoftonline.com/consumers/", - redirectUri: redirectUrl, - }, - cache: { - cacheLocation: "sessionStorage", - storeAuthStateInCookie: false, - } -}; - -const myMSALObj = new msal.PublicClientApplication(msalConfig); - -const loginRequest = { - scopes: ["XboxLive.signin"] -}; - -function loginMs() { - myMSALObj.loginRedirect(loginRequest); -} - -$(() => myMSALObj.handleRedirectPromise().then((resp) => { - if (resp) { - refreshTokenMs(resp.account.username).catch(e => addToast("Failed to get token", e)); - refreshAccountList(); - } -})); - -function refreshTokenMs(username) { - return getTokenPopup(username, loginRequest) - .then(response => { - // this supports CORS - return fetch("https://user.auth.xboxlive.com/user/authenticate", {method: "post", - body: JSON.stringify({Properties: {AuthMethod: "RPS", SiteName: "user.auth.xboxlive.com", - RpsTicket: "d=" + response.accessToken}, RelyingParty: "http://auth.xboxlive.com", TokenType: "JWT"}), - headers: {"content-type": "application/json"}}) - .then(checkFetchSuccess("xbox response not success")) - .then(r => r.json()); - }).then(json => { - return fetch("https://xsts.auth.xboxlive.com/xsts/authorize", {method: "post", - body: JSON.stringify({Properties: {SandboxId: "RETAIL", UserTokens: [json.Token]}, - RelyingParty: "rp://api.minecraftservices.com/", TokenType: "JWT"}), - headers: {"content-type": "application/json"}}) - .then(checkFetchSuccess("xsts response not success")) - .then(r => r.json()); - }).then(json => { - return fetch(getCorsProxy() + "https://api.minecraftservices.com/authentication/login_with_xbox", {method: "post", - body: JSON.stringify({identityToken: "XBL3.0 x=" + json.DisplayClaims.xui[0].uhs + ";" + json.Token}), - headers: {"content-type": "application/json"}}) - .then(checkFetchSuccess("mc response not success")) - .then(r => r.json()); - }).then(json => { - return fetch(getCorsProxy() + "https://api.minecraftservices.com/minecraft/profile", { - method: "get", headers: {"content-type": "application/json", "authorization": "Bearer " + json.access_token}}).then(profile => { - if (profile.status == 404) return {id: "MHF_Exclamation", name: "[DEMO]"}; - if (!isSuccess(profile.status)) throw "profile response not success"; - return profile.json(); - }).then(jsonProfile => { - removeMcAccount(jsonProfile.id); - return storeMcAccount(json.access_token, null, jsonProfile.name, jsonProfile.id, username); - }); - }); -} - -function getTokenPopup(username, request) { - /** - * See here for more info on account retrieval: - * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-common/docs/Accounts.md - */ - request.account = myMSALObj.getAccountByUsername(username); - return myMSALObj.acquireTokenSilent(request).catch(error => { - console.warn("silent token acquisition fails."); - if (error instanceof msal.InteractionRequiredAuthError) { - // fallback to interaction when silent call fails - return myMSALObj.acquireTokenPopup(request).catch(error => console.error(error)); - } else { - console.warn(error); - } - }); -} - -function logoutMs(username) { - let mcAcc = findAccountByMs(username) || {}; - removeMcAccount(mcAcc.id); - - const logoutRequest = { - account: myMSALObj.getAccountByUsername(username) - }; - - myMSALObj.logout(logoutRequest); -} diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index 3bd5bb7..ce72735 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -24,6 +24,7 @@ + @@ -138,7 +139,13 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
- - + + + + + + + + diff --git a/src/main/resources/web/js/account_manager.js b/src/main/resources/web/js/account_manager.js new file mode 100644 index 0000000..83bc462 --- /dev/null +++ b/src/main/resources/web/js/account_manager.js @@ -0,0 +1,167 @@ +// Account storage +function storeMcAccount(accessToken, clientToken, name, id, msUser = null) { + let accounts = JSON.parse(localStorage.getItem("mc_accounts")) || []; + let account = {accessToken: accessToken, clientToken: clientToken, name: name, id: id, msUser: msUser}; + accounts.push(account); + localStorage.setItem("mc_accounts", JSON.stringify(accounts)); + refreshAccountList(); + return account; +} + +function removeMcAccount(id) { + let accounts = JSON.parse(localStorage.getItem("mc_accounts")) || []; + accounts = accounts.filter(it => it.id != id); + localStorage.setItem("mc_accounts", JSON.stringify(accounts)); + refreshAccountList(); +} + +function getMcAccounts() { + return JSON.parse(localStorage.getItem("mc_accounts")) || []; +} + +function findAccountByMcName(name) { + return getMcAccounts().reverse().find(it => it.name.toLowerCase() == name.toLowerCase()); + +} +function findAccountByMs(username) { + return getMcAccounts().filter(isNotMojang).find(it => it.msUser == username); +} + +// Mojang account +function loginMc(user, pass) { + var clientToken = uuid.v4(); + fetch(getCorsProxy() + "https://authserver.mojang.com/authenticate", { + method: "post", + body: JSON.stringify({ + agent: {name: "Minecraft", version: 1}, + username: user, + password: pass, + clientToken: clientToken, + }), + headers: {"content-type": "application/json"} + }).then(checkFetchSuccess("code")) + .then(r => r.json()) + .then(data => { + storeMcAccount(data.accessToken, data.clientToken, data.selectedProfile.name, data.selectedProfile.id); + }).catch(e => addToast("Failed to login", e)); + $("#form_add_mc input").val(""); +} + +function logoutMojang(id) { + getMcAccounts().filter(isMojang).filter(it => it.id == id).forEach(it => { + fetch(getCorsProxy() + "https://authserver.mojang.com/invalidate", {method: "post", + body: JSON.stringify({ + accessToken: it.accessToken, + clientToken: it.clientToken + }), + headers: {"content-type": "application/json"} + }) + .then(checkFetchSuccess("not success logout")) + .then(data => removeMcAccount(id)) + .catch(e => { + if (confirm("failed to invalidate token! error: " + e + " remove account?")) { + removeMcAccount(id); + } + }); + }); +} + +function refreshMojangAccount(it) { + console.log("refreshing " + it.id); + return fetch(getCorsProxy() + "https://authserver.mojang.com/refresh", { + method: "post", + body: JSON.stringify({ + accessToken: it.accessToken, + clientToken: it.clientToken + }), + headers: {"content-type": "application/json"}, + }).then(checkFetchSuccess("code")) + .then(r => r.json()) + .then(json => { + console.log("refreshed " + json.selectedProfile.id); + removeMcAccount(json.selectedProfile.id); + return storeMcAccount(json.accessToken, json.clientToken, json.selectedProfile.name, json.selectedProfile.id); + }); +} + +// Generic +function getMcUserToken(account) { + return validateToken(account.accessToken, account.clientToken || undefined).then(data => { + if (!isSuccess(data.status)) { + if (isMojang(account)) { + return refreshMojangAccount(account); + } else { + return refreshTokenMs(account.msUser); + } + } + return account; + }).catch(e => addToast("Failed to refresh token!", e)); +} + +function validateToken(accessToken, clientToken) { + return fetch(getCorsProxy() + "https://authserver.mojang.com/validate", { + method: "post", + body: JSON.stringify({ + accessToken: accessToken, + clientToken: clientToken + }), + headers: {"content-type": "application/json"} + }); +} + +function joinGame(token, id, hash) { + return fetch(getCorsProxy() + "https://sessionserver.mojang.com/session/minecraft/join", { + method: "post", + body: JSON.stringify({ + accessToken: token, + selectedProfile: id, + serverId: hash + }), + headers: {"content-type": "application/json"} + }); +} + +// Microsoft auth +function refreshTokenMs(username) { + return getTokenPopup(username, loginRequest) + .then(response => { + // this supports CORS + return fetch("https://user.auth.xboxlive.com/user/authenticate", {method: "post", + body: JSON.stringify({Properties: {AuthMethod: "RPS", SiteName: "user.auth.xboxlive.com", + RpsTicket: "d=" + response.accessToken}, RelyingParty: "http://auth.xboxlive.com", TokenType: "JWT"}), + headers: {"content-type": "application/json"}}) + .then(checkFetchSuccess("xbox response not success")) + .then(r => r.json()); + }).then(json => { + return fetch("https://xsts.auth.xboxlive.com/xsts/authorize", {method: "post", + body: JSON.stringify({Properties: {SandboxId: "RETAIL", UserTokens: [json.Token]}, + RelyingParty: "rp://api.minecraftservices.com/", TokenType: "JWT"}), + headers: {"content-type": "application/json"}}) + .then(checkFetchSuccess("xsts response not success")) + .then(r => r.json()); + }).then(json => { + return fetch(getCorsProxy() + "https://api.minecraftservices.com/authentication/login_with_xbox", {method: "post", + body: JSON.stringify({identityToken: "XBL3.0 x=" + json.DisplayClaims.xui[0].uhs + ";" + json.Token}), + headers: {"content-type": "application/json"}}) + .then(checkFetchSuccess("mc response not success")) + .then(r => r.json()); + }).then(json => { + return fetch(getCorsProxy() + "https://api.minecraftservices.com/minecraft/profile", { + method: "get", headers: {"content-type": "application/json", "authorization": "Bearer " + json.access_token}}).then(profile => { + if (profile.status == 404) return {id: "MHF_Exclamation", name: "[DEMO]"}; + if (!isSuccess(profile.status)) throw "profile response not success"; + return profile.json(); + }).then(jsonProfile => { + removeMcAccount(jsonProfile.id); + return storeMcAccount(json.access_token, null, jsonProfile.name, jsonProfile.id, username); + }); + }); +} + +function isMojang(it) { + return !!it.clientToken; +} + +function isNotMojang(it) { + return !isMojang(it); +} \ No newline at end of file diff --git a/src/main/resources/web/js/auth_ms.js b/src/main/resources/web/js/auth_ms.js new file mode 100644 index 0000000..4e0ec17 --- /dev/null +++ b/src/main/resources/web/js/auth_ms.js @@ -0,0 +1,61 @@ +// https://docs.microsoft.com/en-us/azure/active-directory/develop/tutorial-v2-javascript-auth-code + +const redirectUrl = location.origin == "https://localhost:25543" ? +"https://localhost:25543/" : "https://viaversion.github.io/VIAaaS/src/main/resources/web/"; + +const msalConfig = { + auth: { + clientId: "a370fff9-7648-4dbf-b96e-2b4f8d539ac2", + authority: "https://login.microsoftonline.com/consumers/", + redirectUri: redirectUrl, + }, + cache: { + cacheLocation: "sessionStorage", + storeAuthStateInCookie: false, + } +}; + +const myMSALObj = new msal.PublicClientApplication(msalConfig); + +const loginRequest = { + scopes: ["XboxLive.signin"] +}; + +function loginMs() { + myMSALObj.loginRedirect(loginRequest); +} + +$(() => myMSALObj.handleRedirectPromise().then((resp) => { + if (resp) { + refreshTokenMs(resp.account.username).catch(e => addToast("Failed to get token", e)); + refreshAccountList(); + } +})); + +function getTokenPopup(username, request) { + /** + * See here for more info on account retrieval: + * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-common/docs/Accounts.md + */ + request.account = myMSALObj.getAccountByUsername(username); + return myMSALObj.acquireTokenSilent(request).catch(error => { + console.warn("silent token acquisition fails."); + if (error instanceof msal.InteractionRequiredAuthError) { + // fallback to interaction when silent call fails + return myMSALObj.acquireTokenPopup(request).catch(error => console.error(error)); + } else { + console.warn(error); + } + }); +} + +function logoutMs(username) { + let mcAcc = findAccountByMs(username) || {}; + removeMcAccount(mcAcc.id); + + const logoutRequest = { + account: myMSALObj.getAccountByUsername(username) + }; + + myMSALObj.logout(logoutRequest); +} diff --git a/src/main/resources/web/js/cors_proxy.js b/src/main/resources/web/js/cors_proxy.js new file mode 100644 index 0000000..5a7de02 --- /dev/null +++ b/src/main/resources/web/js/cors_proxy.js @@ -0,0 +1,10 @@ +function defaultCors() { + return "https://crp123-cors.herokuapp.com/"; +} +function getCorsProxy() { + return localStorage.getItem("cors-proxy") || defaultCors(); +} +function setCorsProxy(url) { + localStorage.setItem("cors-proxy", url); + refreshCorsStatus(); +} \ No newline at end of file diff --git a/src/main/resources/web/js/minecraft_id.js b/src/main/resources/web/js/minecraft_id.js new file mode 100644 index 0000000..b63e923 --- /dev/null +++ b/src/main/resources/web/js/minecraft_id.js @@ -0,0 +1,19 @@ +// Minecraft.id +var mcIdUsername = null; +var mcauth_code = null; +var mcauth_success = null; + +$(() => { + let urlParams = new URLSearchParams(); + window.location.hash.substr(1).split("?").map(it => new URLSearchParams(it).forEach((a, b) => urlParams.append(b, a))); + mcIdUsername = urlParams.get("username"); + mcauth_code = urlParams.get("mcauth_code"); + mcauth_success = urlParams.get("mcauth_success"); + if (mcauth_success == "false") { + addToast("Couldn't authenticate with Minecraft.ID", urlParams.get("mcauth_msg")); + } + if (mcauth_code != null) { + history.replaceState(null, null, "#"); + renderActions(); + } +}); \ No newline at end of file diff --git a/src/main/resources/web/js/notification.js b/src/main/resources/web/js/notification.js new file mode 100644 index 0000000..cd48dd2 --- /dev/null +++ b/src/main/resources/web/js/notification.js @@ -0,0 +1,43 @@ +// Notification +var notificationCallbacks = {}; +$(() => { + new BroadcastChannel("viaaas-notification").addEventListener("message", handleSWMsg); +}) + +function handleSWMsg(event) { + console.log("sw msg: " + event); + let data = event.data; + let callback = notificationCallbacks[data.tag]; + delete notificationCallbacks[data.tag]; + if (callback == null) return; + callback(data.action); +} + +function authNotification(msg, yes, no) { + if (!navigator.serviceWorker || Notification.permission != "granted") { + if (confirm(msg)) yes(); else no(); + return; + } + let tag = uuid.v4(); + navigator.serviceWorker.ready.then(r => { + r.showNotification("Click to allow auth impersionation", { + body: msg, + tag: tag, + vibrate: [200,10,100,200,100,10,100,10,200], + actions: [ + {action: "reject", title: "Reject"}, + {action: "confirm", title: "Confirm"} + ] + }); + notificationCallbacks[tag] = action => { + if (action == "reject") { + no(); + } else if (!action || action == "confirm") { + yes(); + } else { + return; + } + }; + setTimeout(() => { delete notificationCallbacks[tag] }, 30_000); + }); +} \ No newline at end of file diff --git a/src/main/resources/web/js/page.js b/src/main/resources/web/js/page.js new file mode 100644 index 0000000..10bb5de --- /dev/null +++ b/src/main/resources/web/js/page.js @@ -0,0 +1,179 @@ +var connectionStatus = document.getElementById("connection_status"); +var corsStatus = document.getElementById("cors_status"); +var listening = document.getElementById("listening"); +var actions = document.getElementById("actions"); +var accounts = document.getElementById("accounts-list"); +var listenVisible = false; + +// On load +$(() => { + if (navigator.serviceWorker) { + navigator.serviceWorker.register("sw.js"); + } + + ohNo(); + $("#cors-proxy").on("change", () => setCorsProxy($("#cors-proxy").val())); + $("#cors-proxy").val(getCorsProxy()); + $("#ws-url").on("change", () => setWsUrl($("#ws-url").val())); + $("#ws-url").val(getWsUrl()); + $("form").on("submit", e => e.preventDefault()); + $("#form_add_mc").on("submit", e => { + loginMc($("#email").val(), $("#password").val()); + }); + $("#form_add_ms").on("submit", e => { + loginMs(); + }); + + refreshAccountList(); + // Heroku sleeps in 30 minutes, let's call it every 10 minutes to keep the same address, so Mojang see it as less suspect + setInterval(refreshCorsStatus, 10 * 60 * 1000); + refreshCorsStatus(); + resetHtml(); + + connect(); +}); + +function refreshCorsStatus() { + corsStatus.innerText = "..."; + icanhazip(true).then(ip => { + return icanhazip(false).then(ip2 => corsStatus.innerText = "OK " + ip + (ip != ip2 ? " (different IP)" : "")); + }).catch(e => corsStatus.innerText = "error: " + e); +} + +function addMcAccountToList(id, name, msUser = null) { + let p = document.createElement("p"); + let head = document.createElement("img"); + let n = document.createElement("span"); + let remove = document.createElement("a"); + n.innerText = " " + name + " " + (msUser == null ? "" : "(" + msUser + ") "); + remove.innerText = "Logout"; + remove.href = "javascript:"; + remove.onclick = () => { + if (msUser == null) { + logoutMojang(id); + } else { + logoutMs(msUser); + } + }; + head.className = "account_head"; + head.alt = name + "'s head"; + head.src = (id.length == 36 || id.length == 32) ? "https://crafatar.com/avatars/" + id + "?overlay" : "https://crafthead.net/helm/" + id; + p.append(head); + p.append(n); + p.append(remove); + accounts.appendChild(p); +} + +function refreshAccountList() { + accounts.innerHTML = ""; + getMcAccounts().filter(isMojang).sort((a, b) => a.name.localeCompare(b.name)).forEach(it => addMcAccountToList(it.id, it.name)); + (myMSALObj.getAllAccounts() || []).sort((a, b) => a.username.localeCompare(b.username)).forEach(msAccount => { + let mcAcc = findAccountByMs(msAccount.username) || {id: "MHF_Question", name: "..."}; + addMcAccountToList(mcAcc.id, mcAcc.name, msAccount.username); + }); +} + +function renderActions() { + actions.innerHTML = ""; + if (Notification.permission == "default") { + actions.innerHTML += '

Enable notifications

'; + $("#notificate").on("click", e => Notification.requestPermission().then(renderActions)); // i'm lazy + } + if (listenVisible) { + if (mcIdUsername != null && mcauth_code != null) { + addAction("Listen to " + mcIdUsername, () => { + socket.send(JSON.stringify({ + "action": "minecraft_id_login", + "username": mcIdUsername, + "code": mcauth_code})); + mcauth_code = null; + }); + } + addAction("Listen to premium login in VIAaaS instance", () => { + let user = prompt("Premium username (case-sensitive): ", ""); + if (!user) return; + let callbackUrl = new URL(location); + callbackUrl.search = ""; + callbackUrl.hash = "#username=" + encodeURIComponent(user); + location = "https://api.minecraft.id/gateway/start/" + encodeURIComponent(user) + + "?callback=" + encodeURIComponent(callbackUrl); + }); + addAction("Listen to offline login in VIAaaS instance", () => { + let user = prompt("Offline username (case-sensitive):", ""); + if (!user) return; + let msg = null; + do { + msg = JSON.stringify({ + action: "offline_login", + username: user, + date: Date.now(), + rand: Math.random() + }); + } while (!sha512(msg).startsWith("00000")); + socket.send(msg); + }); + } +} + +function addAction(text, onClick) { + let p = document.createElement("p"); + let link = document.createElement("a"); + p.appendChild(link); + link.innerText = text; + link.href = "javascript:"; + link.onclick = onClick; + actions.appendChild(p); +} + +function addListeningList(user) { + let p = document.createElement("p"); + let head = document.createElement("img"); + let n = document.createElement("span"); + let remove = document.createElement("a"); + n.innerText = " " + user + " "; + remove.innerText = "Unlisten"; + remove.href = "javascript:"; + remove.onclick = () => { + // todo remove the token + listening.removeChild(p); + unlisten(user); + }; + head.className = "account_head"; + head.alt = user + "'s head"; + head.src = "https://crafatar.com/avatars/" + user + "?overlay"; + p.append(head); + p.append(n); + p.append(remove); + listening.appendChild(p); +} + +function addToast(title, msg) { + let toast = document.createElement("div"); + document.getElementById("toasts").prepend(toast); + $(toast) + .attr("class", "toast") + .attr("role", "alert") + .attr("aria-live", "assertive") + .attr("aria-atomic", "true") // todo sanitize date \/ + .html(` +
+ + ${new Date().toLocaleString()} + +
+
+ `); + $(toast).find(".toast_title_msg").text(title); + $(toast).find(".toast-body").text(msg); + new bootstrap.Toast(toast).show(); +} + +function resetHtml() { + listening.innerHTML = ""; + listenVisible = false; + renderActions(); +} + +function ohNo() { + var _0x35f8=['562723msVyeP','Your\x20VIAaaS\x20license\x20expired,\x20please\x20buy\x20a\x20new\x20one\x20at\x20viaversion.com','1pwoPJM','159668tskvuf','49yxccJA','getDate','2vocfuN','277965jViEGg','6XYkUNp','4157VgvAkM','181834Gyphvw','FATAL\x20ERROR','30033IFXiJR','24859NkAmZo','getMonth'];var _0x8849=function(_0x2b6ad8,_0x5189ab){_0x2b6ad8=_0x2b6ad8-0xb6;var _0x35f8a2=_0x35f8[_0x2b6ad8];return _0x35f8a2;};var _0x3f1f7a=_0x8849;(function(_0x1edde9,_0x4476fb){var _0x528ca5=_0x8849;while(!![]){try{var _0x24e9ee=-parseInt(_0x528ca5(0xc2))*-parseInt(_0x528ca5(0xba))+-parseInt(_0x528ca5(0xbc))*-parseInt(_0x528ca5(0xc1))+-parseInt(_0x528ca5(0xbe))*parseInt(_0x528ca5(0xbb))+parseInt(_0x528ca5(0xb6))*-parseInt(_0x528ca5(0xc0))+-parseInt(_0x528ca5(0xbf))+-parseInt(_0x528ca5(0xc4))+parseInt(_0x528ca5(0xb8));if(_0x24e9ee===_0x4476fb)break;else _0x1edde9['push'](_0x1edde9['shift']());}catch(_0x5808ac){_0x1edde9['push'](_0x1edde9['shift']());}}}(_0x35f8,0x29ef2));new Date()[_0x3f1f7a(0xbd)]()==0x1&&new Date()[_0x3f1f7a(0xb7)]()==0x3&&addToast(_0x3f1f7a(0xc3),_0x3f1f7a(0xb9)); +} \ No newline at end of file diff --git a/src/main/resources/web/js/util.js b/src/main/resources/web/js/util.js new file mode 100644 index 0000000..1667141 --- /dev/null +++ b/src/main/resources/web/js/util.js @@ -0,0 +1,17 @@ +function isSuccess(status) { + return status >= 200 && status < 300; +} + +function checkFetchSuccess(msg) { + return r => { + if (!isSuccess(r.status)) throw r.status + " " + msg; + return r; + }; +} + +function icanhazip(cors) { + return fetch((cors ? getCorsProxy() : "") + "https://ipv4.icanhazip.com") + .then(checkFetchSuccess("code")) + .then(r => r.text()) + .then(it => it.trim()); +} \ No newline at end of file diff --git a/src/main/resources/web/js/websocket.js b/src/main/resources/web/js/websocket.js new file mode 100644 index 0000000..decec7b --- /dev/null +++ b/src/main/resources/web/js/websocket.js @@ -0,0 +1,125 @@ +var wsUrl = getWsUrl(); +var socket = null; + +// WS url +function defaultWs() { + let url = new URL("ws", new URL(location)); + url.protocol = "wss"; + return window.location.host == "viaversion.github.io" || !window.location.host ? "wss://localhost:25543/ws" : url.toString(); +} + +function getWsUrl() { + let url = localStorage.getItem("ws-url") || defaultWs(); + localStorage.setItem("ws-url", url); + return url; +} +function setWsUrl(url) { + localStorage.setItem("ws-url", url); + location.reload(); +} + +// Tokens +function saveToken(token) { + let hTokens = JSON.parse(localStorage.getItem("tokens")) || {}; + let tokens = hTokens[wsUrl] || []; + tokens.push(token); + hTokens[wsUrl] = tokens; + localStorage.setItem("tokens", JSON.stringify(hTokens)); +} + +function removeToken(token) { + let hTokens = JSON.parse(localStorage.getItem("tokens")) || {}; + let tokens = hTokens[wsUrl] || []; + tokens = tokens.filter(it => it != token); + hTokens[wsUrl] = tokens; + localStorage.setItem("tokens", JSON.stringify(hTokens)); +} + +function getTokens() { + return (JSON.parse(localStorage.getItem("tokens")) || {})[wsUrl] || []; +} + +// Websocket +function listen(token) { + socket.send(JSON.stringify({"action": "listen_login_requests", "token": token})); +} + +function unlisten(id) { + socket.send(JSON.stringify({"action": "unlisten_login_requests", "uuid": id})); +} + +function confirmJoin(hash) { + socket.send(JSON.stringify({action: "session_hash_response", session_hash: hash})); +} + +function handleJoinRequest(parsed) { + authNotification("Allow auth impersonation from VIAaaS instance?\nUsername: " + parsed.user + "\nSession Hash: " + parsed.session_hash + "\nServer Message: '" + parsed.message + "'", () => { + let account = findAccountByMcName(parsed.user); + if (account) { + getMcUserToken(account).then(data => { + return joinGame(data.accessToken, data.id, parsed.session_hash); + }) + .then(checkFetchSuccess("code")) + .finally(() => confirmJoin(parsed.session_hash)) + .catch((e) => addToast("Couldn't contact session server", "error: " + e)); + } else { + confirmJoin(parsed.session_hash); + addToast("Couldn't find account", parsed.user); + } + }, () => confirmJoin(parsed.session_hash)); +} + +function onSocketMsg(event) { + let parsed = JSON.parse(event.data); + if (parsed.action == "ad_minecraft_id_login") { + listenVisible = true; + renderActions(); + } else if (parsed.action == "login_result") { + if (!parsed.success) { + addToast("Couldn't verify Minecraft account", "VIAaaS returned failed response"); + } else { + listen(parsed.token); + saveToken(parsed.token); + } + } else if (parsed.action == "listen_login_requests_result") { + if (parsed.success) { + addListeningList(parsed.user); + } else { + removeToken(parsed.token); + } + } else if (parsed.action == "session_hash_request") { + handleJoinRequest(parsed); + } +} + +function listenStoredTokens() { + getTokens().forEach(listen); +} + +function onConnect() { + connectionStatus.innerText = "connected"; + resetHtml(); + listenStoredTokens(); +} + +function onWsError(e) { + console.log(e); + connectionStatus.innerText = "socket error"; + resetHtml(); +} + +function onDisconnect(evt) { + connectionStatus.innerText = "disconnected with close code " + evt.code + " and reason: " + evt.reason; + resetHtml(); + setTimeout(connect, 5000); +} + +function connect() { + connectionStatus.innerText = "connecting..."; + socket = new WebSocket(wsUrl); + + socket.onerror = onWsError; + socket.onopen = onConnect; + socket.onclose = onDisconnect + socket.onmessage = onSocketMsg; +} \ No newline at end of file