diff --git a/pom.xml b/pom.xml index b1d54c143..4a6dc8d45 100644 --- a/pom.xml +++ b/pom.xml @@ -122,6 +122,12 @@ ${javafx.version} pom + + org.openjfx + javafx-web + ${javafx.version} + pom + com.jfoenix jfoenix diff --git a/src/main/java/de/bixilon/minosoft/Minosoft.java b/src/main/java/de/bixilon/minosoft/Minosoft.java index 44e3b5831..1cb4f333e 100644 --- a/src/main/java/de/bixilon/minosoft/Minosoft.java +++ b/src/main/java/de/bixilon/minosoft/Minosoft.java @@ -61,6 +61,7 @@ public final class Minosoft { public static void main(String[] args) { MinosoftCommandLineArguments.parseCommandLineArguments(args); Runtime.getRuntime().addShutdownHook(new Thread(() -> shutdown(ShutdownReasons.UNKNOWN), "ShutdownHook")); + Util.initUtilClasses(); Log.info("Starting..."); AsyncTaskWorker taskWorker = new AsyncTaskWorker("StartUp"); @@ -245,4 +246,5 @@ public final class Minosoft { public static CountUpAndDownLatch getStartStatusLatch() { return START_STATUS_LATCH; } + } diff --git a/src/main/java/de/bixilon/minosoft/config/Configuration.java b/src/main/java/de/bixilon/minosoft/config/Configuration.java index e63a929a8..e028d64d0 100644 --- a/src/main/java/de/bixilon/minosoft/config/Configuration.java +++ b/src/main/java/de/bixilon/minosoft/config/Configuration.java @@ -16,6 +16,7 @@ package de.bixilon.minosoft.config; import com.google.common.collect.HashBiMap; import com.google.gson.*; import de.bixilon.minosoft.data.accounts.Account; +import de.bixilon.minosoft.data.accounts.MicrosoftAccount; import de.bixilon.minosoft.data.accounts.MojangAccount; import de.bixilon.minosoft.data.accounts.OfflineAccount; import de.bixilon.minosoft.gui.main.Server; @@ -77,6 +78,7 @@ public class Configuration { Account account = switch (data.get("type").getAsString()) { case "mojang" -> MojangAccount.deserialize(data); case "offline" -> OfflineAccount.deserialize(data); + case "microsoft" -> MicrosoftAccount.deserialize(data); default -> throw new IllegalArgumentException("Unexpected value: " + data.get("type").getAsString()); }; this.accountList.put(account.getId(), account); @@ -105,16 +107,16 @@ public class Configuration { JsonObject jsonObject = DEFAULT_CONFIGURATION.deepCopy(); synchronized (this.config) { - // accounts - JsonObject accountsEntriesJson = jsonObject.getAsJsonObject("servers").getAsJsonObject("entries"); + // servers + JsonObject serversEntriesJson = jsonObject.getAsJsonObject("servers").getAsJsonObject("entries"); for (Map.Entry entry : this.serverList.entrySet()) { - accountsEntriesJson.add(String.valueOf(entry.getKey()), entry.getValue().serialize()); + serversEntriesJson.add(String.valueOf(entry.getKey()), entry.getValue().serialize()); } - // servers - JsonObject serversEntriesJson = jsonObject.getAsJsonObject("accounts").getAsJsonObject("entries"); + // accounts + JsonObject accountsEntriesJson = jsonObject.getAsJsonObject("accounts").getAsJsonObject("entries"); for (Map.Entry entry : this.accountList.entrySet()) { - serversEntriesJson.add(entry.getKey(), entry.getValue().serialize()); + accountsEntriesJson.add(entry.getKey(), entry.getValue().serialize()); } // rest of data diff --git a/src/main/java/de/bixilon/minosoft/data/accounts/Account.java b/src/main/java/de/bixilon/minosoft/data/accounts/Account.java index 55d401019..a0a676afd 100644 --- a/src/main/java/de/bixilon/minosoft/data/accounts/Account.java +++ b/src/main/java/de/bixilon/minosoft/data/accounts/Account.java @@ -15,20 +15,34 @@ package de.bixilon.minosoft.data.accounts; import com.google.gson.JsonObject; import de.bixilon.minosoft.Minosoft; +import de.bixilon.minosoft.gui.main.cells.AccountListCell; +import de.bixilon.minosoft.util.logging.Log; import de.bixilon.minosoft.util.mojang.api.exceptions.MojangJoinServerErrorException; import de.bixilon.minosoft.util.mojang.api.exceptions.NoNetworkConnectionException; +import javafx.application.Platform; import java.util.UUID; public abstract class Account { - private final String username; - private final UUID uuid; + protected final String username; + protected final UUID uuid; protected Account(String username, UUID uuid) { this.username = username; this.uuid = uuid; } + public static void addAccount(Account account) { + Minosoft.getConfig().putAccount(account); + account.saveToConfig(); + Log.info(String.format("Added and saved account (type=%s, id=%s, username=%s, uuid=%s)", account.getClass().getSimpleName(), account.getId(), account.getUsername(), account.getUUID())); + Platform.runLater(() -> AccountListCell.ACCOUNT_LIST_VIEW.getItems().add(account)); + if (Minosoft.getConfig().getSelectedAccount() == null) { + // select account + Minosoft.selectAccount(account); + } + } + public String getUsername() { return this.username; } diff --git a/src/main/java/de/bixilon/minosoft/data/accounts/MicrosoftAccount.java b/src/main/java/de/bixilon/minosoft/data/accounts/MicrosoftAccount.java new file mode 100644 index 000000000..1374e91a1 --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/data/accounts/MicrosoftAccount.java @@ -0,0 +1,37 @@ +/* + * Minosoft + * Copyright (C) 2020 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program.If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.data.accounts; + +import com.google.gson.JsonObject; +import de.bixilon.minosoft.util.Util; + +import java.util.UUID; + +public class MicrosoftAccount extends MojangAccount { + + public MicrosoftAccount(String accessToken, String id, UUID uuid, String username) { + super(accessToken, id, uuid, username, null); + } + + public static MicrosoftAccount deserialize(JsonObject json) { + return new MicrosoftAccount(json.get("accessToken").getAsString(), json.get("id").getAsString(), Util.getUUIDFromString(json.get("uuid").getAsString()), json.get("username").getAsString()); + } + + public JsonObject serialize() { + JsonObject json = super.serialize(); + json.addProperty("type", "microsoft"); + json.remove("email"); + return json; + } +} diff --git a/src/main/java/de/bixilon/minosoft/data/accounts/MojangAccount.java b/src/main/java/de/bixilon/minosoft/data/accounts/MojangAccount.java index 9e9704d07..850fced6b 100644 --- a/src/main/java/de/bixilon/minosoft/data/accounts/MojangAccount.java +++ b/src/main/java/de/bixilon/minosoft/data/accounts/MojangAccount.java @@ -24,11 +24,11 @@ import de.bixilon.minosoft.util.mojang.api.exceptions.NoNetworkConnectionExcepti import java.util.UUID; public class MojangAccount extends Account { - private final String id; - private final String email; - private String accessToken; - private RefreshStates lastRefreshStatus; - private boolean needsRefresh = true; + protected final String id; + protected final String email; + protected String accessToken; + protected RefreshStates lastRefreshStatus; + protected boolean needsRefresh = true; public MojangAccount(String username, JsonObject json) { super(json.getAsJsonObject("selectedProfile").get("name").getAsString(), Util.getUUIDFromString(json.getAsJsonObject("selectedProfile").get("id").getAsString())); diff --git a/src/main/java/de/bixilon/minosoft/data/locale/Strings.java b/src/main/java/de/bixilon/minosoft/data/locale/Strings.java index f1e92a8f0..0ef81d136 100644 --- a/src/main/java/de/bixilon/minosoft/data/locale/Strings.java +++ b/src/main/java/de/bixilon/minosoft/data/locale/Strings.java @@ -53,6 +53,7 @@ public enum Strings { SERVER_ACTION_DELETE, SESSIONS_ACTION_DISCONNECT, + ACCOUNT_MODAL_MENU_ADD_MICROSOFT_ACCOUNT, ACCOUNT_MODAL_MENU_ADD_MOJANG_ACCOUNT, ACCOUNT_MODAL_MENU_ADD_OFFLINE_ACCOUNT, MAIN_WINDOW_TITLE, @@ -89,6 +90,8 @@ public enum Strings { LOGIN_OFFLINE_UUID, LOGIN_OFFLINE_ADD_BUTTON, + LOGIN_MICROSOFT_DIALOG_TITLE, + MINOSOFT_STILL_STARTING_TITLE, MINOSOFT_STILL_STARTING_HEADER, diff --git a/src/main/java/de/bixilon/minosoft/gui/main/AccountWindow.java b/src/main/java/de/bixilon/minosoft/gui/main/AccountWindow.java index 771d2aa13..052d1a1a8 100644 --- a/src/main/java/de/bixilon/minosoft/gui/main/AccountWindow.java +++ b/src/main/java/de/bixilon/minosoft/gui/main/AccountWindow.java @@ -34,6 +34,7 @@ public class AccountWindow implements Initializable { public BorderPane accountPane; public MenuItem menuAddMojangAccount; public MenuItem menuAddOfflineAccount; + public MenuItem menuAddMicrosoftAccount; @Override public void initialize(URL url, ResourceBundle resourceBundle) { @@ -43,13 +44,23 @@ public class AccountWindow implements Initializable { AccountListCell.ACCOUNT_LIST_VIEW.setItems(accounts); this.accountPane.setCenter(AccountListCell.ACCOUNT_LIST_VIEW); + this.menuAddMicrosoftAccount.setText(LocaleManager.translate(Strings.ACCOUNT_MODAL_MENU_ADD_MICROSOFT_ACCOUNT)); this.menuAddMojangAccount.setText(LocaleManager.translate(Strings.ACCOUNT_MODAL_MENU_ADD_MOJANG_ACCOUNT)); this.menuAddOfflineAccount.setText(LocaleManager.translate(Strings.ACCOUNT_MODAL_MENU_ADD_OFFLINE_ACCOUNT)); } + public void addMicrosoftAccount() { + try { + GUITools.showPane("/layout/dialogs/login/microsoft.fxml", Modality.APPLICATION_MODAL, LocaleManager.translate(Strings.LOGIN_MICROSOFT_DIALOG_TITLE)); + } catch (IOException e) { + e.printStackTrace(); + Minosoft.shutdown(e.getMessage(), ShutdownReasons.LAUNCHER_FXML_LOAD_ERROR); + } + } + public void addMojangAccount() { try { - GUITools.showPane("/layout/dialogs/login_mojang.fxml", Modality.APPLICATION_MODAL, LocaleManager.translate(Strings.LOGIN_MOJANG_DIALOG_TITLE)); + GUITools.showPane("/layout/dialogs/login/mojang.fxml", Modality.APPLICATION_MODAL, LocaleManager.translate(Strings.LOGIN_MOJANG_DIALOG_TITLE)); } catch (IOException e) { e.printStackTrace(); Minosoft.shutdown(e.getMessage(), ShutdownReasons.LAUNCHER_FXML_LOAD_ERROR); @@ -58,7 +69,7 @@ public class AccountWindow implements Initializable { public void addOfflineAccount() { try { - GUITools.showPane("/layout/dialogs/login_offline.fxml", Modality.APPLICATION_MODAL, LocaleManager.translate(Strings.LOGIN_OFFLINE_DIALOG_TITLE)); + GUITools.showPane("/layout/dialogs/login/offline.fxml", Modality.APPLICATION_MODAL, LocaleManager.translate(Strings.LOGIN_OFFLINE_DIALOG_TITLE)); } catch (IOException e) { e.printStackTrace(); Minosoft.shutdown(e.getMessage(), ShutdownReasons.LAUNCHER_FXML_LOAD_ERROR); diff --git a/src/main/java/de/bixilon/minosoft/gui/main/dialogs/login/MicrosoftLoginController.java b/src/main/java/de/bixilon/minosoft/gui/main/dialogs/login/MicrosoftLoginController.java new file mode 100644 index 000000000..2bfaa2e0e --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/gui/main/dialogs/login/MicrosoftLoginController.java @@ -0,0 +1,57 @@ +/* + * Minosoft + * Copyright (C) 2020 Moritz Zwerger + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program.If not, see . + * + * This software is not affiliated with Mojang AB, the original developer of Minecraft. + */ + +package de.bixilon.minosoft.gui.main.dialogs.login; + +import de.bixilon.minosoft.protocol.protocol.ProtocolDefinition; +import javafx.concurrent.Worker; +import javafx.event.ActionEvent; +import javafx.fxml.Initializable; +import javafx.scene.layout.HBox; +import javafx.scene.web.WebView; + +import java.net.CookieHandler; +import java.net.CookieManager; +import java.net.URL; +import java.util.ResourceBundle; + +public class MicrosoftLoginController implements Initializable { + public HBox hBox; + public WebView webView; + + @Override + public void initialize(URL url, ResourceBundle resourceBundle) { + CookieHandler.setDefault(new CookieManager()); + + this.webView.getEngine().setJavaScriptEnabled(true); + this.webView.setContextMenuEnabled(false); + this.webView.getEngine().loadContent("Loading..."); + this.webView.getEngine().getLoadWorker().stateProperty().addListener((observable, oldValue, newValue) -> { + if (newValue == Worker.State.SUCCEEDED) { + if (this.webView.getEngine().getLocation().startsWith("ms-xal-" + ProtocolDefinition.MICROSOFT_ACCOUNT_APPLICATION_ID)) { + // login is being handled by MicrosoftOAuthUtils. We can go now... + this.hBox.getScene().getWindow().hide(); + } + } + }); + requestOathFlowToken(); + } + + private void requestOathFlowToken() { + this.webView.getEngine().load(ProtocolDefinition.MICROSOFT_ACCOUNT_OAUTH_FLOW_URL); + } + + public void login(ActionEvent event) { + event.consume(); + } +} diff --git a/src/main/java/de/bixilon/minosoft/gui/main/dialogs/MojangLoginController.java b/src/main/java/de/bixilon/minosoft/gui/main/dialogs/login/MojangLoginController.java similarity index 83% rename from src/main/java/de/bixilon/minosoft/gui/main/dialogs/MojangLoginController.java rename to src/main/java/de/bixilon/minosoft/gui/main/dialogs/login/MojangLoginController.java index c7a7aacc8..a610e253b 100644 --- a/src/main/java/de/bixilon/minosoft/gui/main/dialogs/MojangLoginController.java +++ b/src/main/java/de/bixilon/minosoft/gui/main/dialogs/login/MojangLoginController.java @@ -11,17 +11,15 @@ * This software is not affiliated with Mojang AB, the original developer of Minecraft. */ -package de.bixilon.minosoft.gui.main.dialogs; +package de.bixilon.minosoft.gui.main.dialogs.login; import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXPasswordField; import com.jfoenix.controls.JFXTextField; -import de.bixilon.minosoft.Minosoft; +import de.bixilon.minosoft.data.accounts.Account; import de.bixilon.minosoft.data.accounts.MojangAccount; import de.bixilon.minosoft.data.locale.LocaleManager; import de.bixilon.minosoft.data.locale.Strings; -import de.bixilon.minosoft.gui.main.cells.AccountListCell; -import de.bixilon.minosoft.util.logging.Log; import de.bixilon.minosoft.util.mojang.api.MojangAuthentication; import de.bixilon.minosoft.util.mojang.api.exceptions.AuthenticationException; import de.bixilon.minosoft.util.mojang.api.exceptions.NoNetworkConnectionException; @@ -85,19 +83,10 @@ public class MojangLoginController implements Initializable { new Thread(() -> { // ToDo: recycle thread try { MojangAccount account = MojangAuthentication.login(this.email.getText(), this.password.getText()); + Account.addAccount(account); + + Platform.runLater(this::close); - account.setNeedRefresh(false); - Minosoft.getConfig().putAccount(account); - account.saveToConfig(); - Log.info(String.format("Added and saved account (type=mojang, username=%s, email=%s, uuid=%s)", account.getUsername(), account.getEmail(), account.getUUID())); - Platform.runLater(() -> { - AccountListCell.ACCOUNT_LIST_VIEW.getItems().add(account); - close(); - }); - if (Minosoft.getConfig().getSelectedAccount() == null) { - // select account - Minosoft.selectAccount(account); - } } catch (AuthenticationException | NoNetworkConnectionException e) { e.printStackTrace(); Platform.runLater(() -> { diff --git a/src/main/java/de/bixilon/minosoft/gui/main/dialogs/OfflineLoginController.java b/src/main/java/de/bixilon/minosoft/gui/main/dialogs/login/OfflineLoginController.java similarity index 98% rename from src/main/java/de/bixilon/minosoft/gui/main/dialogs/OfflineLoginController.java rename to src/main/java/de/bixilon/minosoft/gui/main/dialogs/login/OfflineLoginController.java index 766b538b1..204b898ac 100644 --- a/src/main/java/de/bixilon/minosoft/gui/main/dialogs/OfflineLoginController.java +++ b/src/main/java/de/bixilon/minosoft/gui/main/dialogs/login/OfflineLoginController.java @@ -11,7 +11,7 @@ * This software is not affiliated with Mojang AB, the original developer of Minecraft. */ -package de.bixilon.minosoft.gui.main.dialogs; +package de.bixilon.minosoft.gui.main.dialogs.login; import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXTextField; diff --git a/src/main/java/de/bixilon/minosoft/protocol/protocol/OutByteBuffer.java b/src/main/java/de/bixilon/minosoft/protocol/protocol/OutByteBuffer.java index 1085c6077..c4fe463dc 100644 --- a/src/main/java/de/bixilon/minosoft/protocol/protocol/OutByteBuffer.java +++ b/src/main/java/de/bixilon/minosoft/protocol/protocol/OutByteBuffer.java @@ -13,12 +13,12 @@ package de.bixilon.minosoft.protocol.protocol; -import com.google.gson.Gson; import com.google.gson.JsonObject; import de.bixilon.minosoft.data.inventory.Slot; import de.bixilon.minosoft.data.text.ChatComponent; import de.bixilon.minosoft.data.world.BlockPosition; import de.bixilon.minosoft.protocol.network.Connection; +import de.bixilon.minosoft.util.Util; import de.bixilon.minosoft.util.nbt.tag.CompoundTag; import java.nio.charset.StandardCharsets; @@ -82,7 +82,7 @@ public class OutByteBuffer { } public void writeJSON(JsonObject json) { - writeString(new Gson().toJson(json)); + writeString(Util.GSON.toJson(json)); } public void writeString(String string) { diff --git a/src/main/java/de/bixilon/minosoft/protocol/protocol/ProtocolDefinition.java b/src/main/java/de/bixilon/minosoft/protocol/protocol/ProtocolDefinition.java index 1e86e7671..4dba3ac0a 100644 --- a/src/main/java/de/bixilon/minosoft/protocol/protocol/ProtocolDefinition.java +++ b/src/main/java/de/bixilon/minosoft/protocol/protocol/ProtocolDefinition.java @@ -71,6 +71,15 @@ public final class ProtocolDefinition { public static final String MOJANG_URL_JOIN = "https://sessionserver.mojang.com/session/minecraft/join"; public static final String MOJANG_URL_REFRESH = "https://authserver.mojang.com/refresh"; + public static final String MICROSOFT_ACCOUNT_APPLICATION_ID = "00000000402b5328"; // ToDo: Should we use our own application id? + // public static final String MICROSOFT_ACCOUNT_APPLICATION_ID = "fe6f0fbf-3038-486a-9c84-6a28b71e0455"; + public static final String MICROSOFT_ACCOUNT_OAUTH_FLOW_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=" + MICROSOFT_ACCOUNT_APPLICATION_ID + "&scope=XboxLive.signin%20offline_access&response_type=code"; + public static final String MICROSOFT_ACCOUNT_AUTH_TOKEN_URL = "https://login.live.com/oauth20_token.srf"; + public static final String MICROSOFT_ACCOUNT_XBOX_LIVE_AUTHENTICATE_URL = "https://user.auth.xboxlive.com/user/authenticate"; + public static final String MICROSOFT_ACCOUNT_XSTS_URL = "https://xsts.auth.xboxlive.com/xsts/authorize"; + public static final String MICROSOFT_ACCOUNT_MINECRAFT_LOGIN_WITH_XBOX_URL = "https://api.minecraftservices.com/authentication/login_with_xbox"; + public static final String MICROSOFT_ACCOUNT_GET_MOJANG_PROFILE_URL = "https://api.minecraftservices.com/minecraft/profile"; + public static final char[] OBFUSCATED_CHARS = "!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~".toCharArray(); static { diff --git a/src/main/java/de/bixilon/minosoft/util/HTTP.java b/src/main/java/de/bixilon/minosoft/util/HTTP.java index 36c67d01f..5f9134604 100644 --- a/src/main/java/de/bixilon/minosoft/util/HTTP.java +++ b/src/main/java/de/bixilon/minosoft/util/HTTP.java @@ -22,26 +22,64 @@ import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.util.HashMap; public final class HTTP { - public static HttpResponse postJson(String url, JsonObject json) throws IOException, InterruptedException { + public static HttpResponse postJson(String url, String json, HashMap headers) throws IOException, InterruptedException { + headers.put("Content-Type", "application/json"); + headers.put("Accept", "application/json"); + HttpClient client = HttpClient.newHttpClient(); - HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).POST(HttpRequest.BodyPublishers.ofString(json.toString())).header("Content-Type", "application/json").build(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .POST(HttpRequest.BodyPublishers.ofString(json)) + .headers(Util.headersMapToArray(headers)) + .build(); + return client.send(request, HttpResponse.BodyHandlers.ofString()); + } + + public static HttpResponse postJson(String url, JsonObject json) throws IOException, InterruptedException { + return postJson(url, Util.GSON.toJson(json), new HashMap<>()); + } + + public static HttpResponse postJson(String url, String json) throws IOException, InterruptedException { + return postJson(url, json, new HashMap<>()); + } + + public static HttpResponse get(String url, HashMap headers) throws IOException, InterruptedException { + HttpClient client = HttpClient.newHttpClient(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .headers(Util.headersMapToArray(headers)) + .build(); return client.send(request, HttpResponse.BodyHandlers.ofString()); } public static HttpResponse get(String url) throws IOException, InterruptedException { - HttpClient client = HttpClient.newHttpClient(); - HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).build(); - return client.send(request, HttpResponse.BodyHandlers.ofString()); + return get(url, new HashMap<>()); } - public static JsonElement getJson(String url) throws IOException, InterruptedException { - HttpResponse response = get(url); + + public static JsonElement getJson(String url, HashMap headers) throws IOException, InterruptedException { + HttpResponse response = get(url, headers); if (response.statusCode() != 200) { throw new IOException(); } return JsonParser.parseString(response.body()); } + + public static JsonElement getJson(String url) throws IOException, InterruptedException { + return getJson(url, new HashMap<>()); + } + + public static HttpResponse postData(String url, HashMap data) throws IOException, InterruptedException { + HttpClient client = HttpClient.newHttpClient(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .POST(HttpRequest.BodyPublishers.ofString(Util.mapToUrlQuery(data))) + .header("Content-Type", "application/x-www-form-urlencoded") + .build(); + return client.send(request, HttpResponse.BodyHandlers.ofString()); + } } diff --git a/src/main/java/de/bixilon/minosoft/util/Util.java b/src/main/java/de/bixilon/minosoft/util/Util.java index 696355fdc..34be4c8bf 100644 --- a/src/main/java/de/bixilon/minosoft/util/Util.java +++ b/src/main/java/de/bixilon/minosoft/util/Util.java @@ -14,24 +14,26 @@ package de.bixilon.minosoft.util; import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.google.gson.Gson; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.gson.stream.JsonReader; import de.bixilon.minosoft.protocol.network.Connection; import de.bixilon.minosoft.protocol.protocol.InByteBuffer; import de.bixilon.minosoft.protocol.protocol.ProtocolDefinition; +import de.bixilon.minosoft.util.logging.Log; +import de.bixilon.minosoft.util.microsoft.MicrosoftOAuthUtils; import org.apache.commons.compress.archivers.tar.TarArchiveEntry; import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; import java.io.*; import java.lang.reflect.Field; import java.net.URL; +import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.util.HashMap; -import java.util.Random; -import java.util.UUID; +import java.util.*; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadLocalRandom; import java.util.regex.Pattern; @@ -46,6 +48,8 @@ public final class Util { private static final Field JSON_READER_POS_FIELD; private static final Field JSON_READER_LINE_START_FIELD; + public static final Gson GSON = new Gson(); + static { new JsonReader(new StringReader("")); Class jsonReadClass = JsonReader.class; @@ -324,4 +328,48 @@ public final class Util { throw new IllegalArgumentException("Not a valid url:" + url); } } + + public static void forceClassInit(Class clazz) { + try { + Class.forName(clazz.getName(), true, clazz.getClassLoader()); + } catch (ClassNotFoundException exception) { + throw new RuntimeException(exception); + } + } + + public static void initUtilClasses() { + forceClassInit(Log.class); + forceClassInit(MicrosoftOAuthUtils.class); + } + + public static Map urlQueryToMap(String query) { + Map map = new HashMap<>(); + for (String parameter : query.split("&")) { + String[] split = parameter.split("="); + map.put(split[0], split[1]); + } + return map; + } + + public static String mapToUrlQuery(Map data) { + StringBuilder builder = new StringBuilder(); + for (Map.Entry entry : data.entrySet()) { + if (!builder.isEmpty()) { + builder.append("&"); + } + builder.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)); + builder.append("="); + builder.append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)); + } + return builder.toString(); + } + + public static String[] headersMapToArray(Map headers) { + List headerList = new ArrayList<>(); + for (var entry : headers.entrySet()) { + headerList.add(entry.getKey()); + headerList.add(entry.getValue()); + } + return headerList.toArray(new String[]{}); + } } diff --git a/src/main/java/de/bixilon/minosoft/util/microsoft/LoginException.kt b/src/main/java/de/bixilon/minosoft/util/microsoft/LoginException.kt new file mode 100644 index 000000000..b9a574ddc --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/util/microsoft/LoginException.kt @@ -0,0 +1,3 @@ +package de.bixilon.minosoft.util.microsoft + +class LoginException(val errorCode: Int, override val message: String, val errorMessage: String) : Exception() diff --git a/src/main/java/de/bixilon/minosoft/util/microsoft/MicrosoftOAuthUtils.kt b/src/main/java/de/bixilon/minosoft/util/microsoft/MicrosoftOAuthUtils.kt new file mode 100644 index 000000000..71045ecbe --- /dev/null +++ b/src/main/java/de/bixilon/minosoft/util/microsoft/MicrosoftOAuthUtils.kt @@ -0,0 +1,173 @@ +package de.bixilon.minosoft.util.microsoft + +import com.google.gson.JsonParser +import com.jfoenix.controls.JFXAlert +import com.jfoenix.controls.JFXDialogLayout +import de.bixilon.minosoft.config.StaticConfiguration +import de.bixilon.minosoft.data.accounts.Account +import de.bixilon.minosoft.data.accounts.MicrosoftAccount +import de.bixilon.minosoft.gui.main.GUITools +import de.bixilon.minosoft.protocol.protocol.ProtocolDefinition +import de.bixilon.minosoft.util.HTTP +import de.bixilon.minosoft.util.Util +import de.bixilon.minosoft.util.logging.Log +import javafx.application.Platform +import javafx.scene.control.TextArea +import javafx.scene.text.Text +import javafx.stage.Stage +import java.net.URL +import java.net.URLConnection +import java.net.URLStreamHandler + +object MicrosoftOAuthUtils { + val NULL_URL_CONNECTION: URLConnection = object : URLConnection(null) { + override fun connect() {} + } + + fun loginToMicrosoftAccount(authorizationCode: String) { + Log.verbose("Logging into microsoft account...") + try { + val authorizationToken = getAuthorizationToken(authorizationCode) + val xboxLiveToken = getXboxLiveToken(authorizationToken) + val xstsToken = getXSTSToken(xboxLiveToken.first) + + val microsoftAccount = getMicrosoftAccount(getMinecraftAccessToken(xboxLiveToken.second, xstsToken)) + Account.addAccount(microsoftAccount) + } catch (exception: Exception) { + Log.warn("Can not login into microsoft account") + exception.printStackTrace() + + if (!StaticConfiguration.HEADLESS_MODE) { + var message = "Could not login!" + var errorMessage = exception.javaClass.canonicalName + ": " + exception.message + if (exception is LoginException) { + message = "${exception.message} (${exception.errorCode})" + errorMessage = exception.errorMessage + } + + Platform.runLater { + val dialog = JFXAlert() + GUITools.initializePane(dialog.dialogPane) + // Do not translate this, translations might fail to load... + dialog.title = "Login error" + val layout = JFXDialogLayout() + layout.setHeading(Text(message)) + val text = TextArea(errorMessage) + text.isEditable = false + text.isWrapText = true + layout.setBody(text) + dialog.dialogPane.content = layout + val stage = dialog.dialogPane.scene.window as Stage + stage.toFront() + dialog.show() + } + } + } + } + + fun getAuthorizationToken(authorizationCode: String): String { + val data = mapOf( + "client_id" to ProtocolDefinition.MICROSOFT_ACCOUNT_APPLICATION_ID, + "code" to authorizationCode, + "grant_type" to "authorization_code", + "scope" to "service::user.auth.xboxlive.com::MBI_SSL", + ) + val response = HTTP.postData(ProtocolDefinition.MICROSOFT_ACCOUNT_AUTH_TOKEN_URL, HashMap(data)) + if (response.statusCode() != 200) { + throw LoginException(response.statusCode(), "Could not get authorization token ", response.body()) + } + val body = JsonParser.parseString(response.body()).asJsonObject + return body["access_token"]!!.asString + } + + /** + * returns A: XBL Token; B: UHS Token + */ + fun getXboxLiveToken(authorizationToken: String): Pair { + val payload = mapOf( + "Properties" to mapOf( + "AuthMethod" to "RPS", + "SiteName" to "user.auth.xboxlive.com", + "RpsTicket" to authorizationToken + ), + "RelyingParty" to "http://auth.xboxlive.com", + "TokenType" to "JWT", + ) + val response = HTTP.postJson(ProtocolDefinition.MICROSOFT_ACCOUNT_XBOX_LIVE_AUTHENTICATE_URL, Util.GSON.toJson(payload)) + + if (response.statusCode() != 200) { + throw LoginException(response.statusCode(), "Could not get authenticate against xbox live ", response.body()) + } + val body = JsonParser.parseString(response.body()).asJsonObject + return Pair(body["Token"]!!.asString, body["DisplayClaims"].asJsonObject["xui"].asJsonArray[0].asJsonObject["uhs"].asString) + } + + fun getXSTSToken(xBoxLiveToken: String): String { + val payload = mapOf( + "Properties" to mapOf( + "SandboxId" to "RETAIL", + "UserTokens" to listOf(xBoxLiveToken) + ), + "RelyingParty" to "rp://api.minecraftservices.com/", + "TokenType" to "JWT", + ) + val response = HTTP.postJson(ProtocolDefinition.MICROSOFT_ACCOUNT_XSTS_URL, Util.GSON.toJson(payload)) + + if (response.statusCode() != 200) { + val error = JsonParser.parseString(response.body()).asJsonObject + val errorMessage = when (error["XErr"].asLong) { + 2148916233 -> "You don't have an XBox account!" + 2148916238 -> "This account is a child account!" + else -> error["Message"].asString + } + throw LoginException(response.statusCode(), "Could not get authenticate against XSTS token ", errorMessage) + } + val body = JsonParser.parseString(response.body()).asJsonObject + return body["Token"].asString!! + } + + fun getMinecraftAccessToken(uhs: String, xstsToken: String): String { + val payload = mapOf( + "identityToken" to "XBL3.0 x=${uhs};${xstsToken}" + ) + val response = HTTP.postJson(ProtocolDefinition.MICROSOFT_ACCOUNT_MINECRAFT_LOGIN_WITH_XBOX_URL, Util.GSON.toJson(payload)) + + if (response.statusCode() != 200) { + val error = JsonParser.parseString(response.body()).asJsonObject + throw LoginException(response.statusCode(), "Could not get minecraft access token ", error["errorMessage"].asString) + } + val body = JsonParser.parseString(response.body()).asJsonObject + return body["access_token"].asString!! + } + + fun getMicrosoftAccount(bearerToken: String): MicrosoftAccount { + val response = HTTP.get(ProtocolDefinition.MICROSOFT_ACCOUNT_GET_MOJANG_PROFILE_URL, HashMap(mapOf( + "Authorization" to "Bearer $bearerToken" + ))) + + if (response.statusCode() != 200) { + val errorMessage = when (response.statusCode()) { + 404 -> "You don't have a copy of minecraft!" + else -> JsonParser.parseString(response.body()).asJsonObject["errorMessage"].asString + } + throw LoginException(response.statusCode(), "Could not get minecraft profile", errorMessage) + } + + val body = JsonParser.parseString(response.body()).asJsonObject + return MicrosoftAccount(bearerToken, body["id"].asString!!, Util.getUUIDFromString(body["id"].asString!!), body["name"].asString!!) + } + + init { + URL.setURLStreamHandlerFactory { + if (it == "ms-xal-" + ProtocolDefinition.MICROSOFT_ACCOUNT_APPLICATION_ID) { + return@setURLStreamHandlerFactory object : URLStreamHandler() { + override fun openConnection(url: URL): URLConnection { + loginToMicrosoftAccount(Util.urlQueryToMap(url.query)["code"]!!) + return NULL_URL_CONNECTION + } + } + } + return@setURLStreamHandlerFactory null + } + } +} diff --git a/src/main/resources/assets/locale/de_DE.json b/src/main/resources/assets/locale/de_DE.json index 73b5eb138..d858a657b 100644 --- a/src/main/resources/assets/locale/de_DE.json +++ b/src/main/resources/assets/locale/de_DE.json @@ -36,6 +36,7 @@ "SERVER_ACTION_SESSIONS": "Verbindungen", "SERVER_ACTION_DELETE": "Löschen", "SESSIONS_ACTION_DISCONNECT": "Trennen", + "ACCOUNT_MODAL_MENU_ADD_MICROSOFT_ACCOUNT": "Anmelden (Microsoft)", "ACCOUNT_MODAL_MENU_ADD_MOJANG_ACCOUNT": "Anmelden (Mojang)", "ACCOUNT_MODAL_MENU_ADD_OFFLINE_ACCOUNT": "Hinzufügen (Offline)", "MAIN_WINDOW_TITLE": "Minosoft", @@ -63,6 +64,7 @@ "LOGIN_OFFLINE_USERNAME": "Benutzername", "LOGIN_OFFLINE_UUID": "(UUID)", "LOGIN_OFFLINE_ADD_BUTTON": "Hinzufügen", + "LOGIN_MICROSOFT_DIALOG_TITLE": "Anmelden (Microsoft) - Mojang", "ERROR": "Fehler", "MINOSOFT_STILL_STARTING_TITLE": "Bitte warten", "MINOSOFT_STILL_STARTING_HEADER": "Minosoft muss noch ein paar Dinge erledigen, bevor du es verwenden kannst.\nBitte warte noch ein paar Sekunden...", diff --git a/src/main/resources/assets/locale/en_US.json b/src/main/resources/assets/locale/en_US.json index 6b7cb04c7..813174c05 100644 --- a/src/main/resources/assets/locale/en_US.json +++ b/src/main/resources/assets/locale/en_US.json @@ -37,6 +37,7 @@ "SERVER_ACTION_SESSIONS": "Sessions", "SERVER_ACTION_DELETE": "Delete", "SESSIONS_ACTION_DISCONNECT": "Disconnect", + "ACCOUNT_MODAL_MENU_ADD_MICROSOFT_ACCOUNT": "Login (Microsoft)", "ACCOUNT_MODAL_MENU_ADD_MOJANG_ACCOUNT": "Login (Mojang)", "ACCOUNT_MODAL_MENU_ADD_OFFLINE_ACCOUNT": "Add (Offline)", "MAIN_WINDOW_TITLE": "Minosoft", @@ -64,6 +65,7 @@ "LOGIN_OFFLINE_USERNAME": "Username", "LOGIN_OFFLINE_UUID": "(UUID)", "LOGIN_OFFLINE_ADD_BUTTON": "Add", + "LOGIN_MICROSOFT_DIALOG_TITLE": "Login (Microsoft) - Minosoft", "ERROR": "Error", "MINOSOFT_STILL_STARTING_TITLE": "Please wait", "MINOSOFT_STILL_STARTING_HEADER": "Minosoft is still starting up...", diff --git a/src/main/resources/layout/accounts.fxml b/src/main/resources/layout/accounts.fxml index cf1a6748d..05dbfad0f 100644 --- a/src/main/resources/layout/accounts.fxml +++ b/src/main/resources/layout/accounts.fxml @@ -16,8 +16,9 @@ - + + diff --git a/src/main/resources/layout/dialogs/login/microsoft.fxml b/src/main/resources/layout/dialogs/login/microsoft.fxml new file mode 100644 index 000000000..435091415 --- /dev/null +++ b/src/main/resources/layout/dialogs/login/microsoft.fxml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/main/resources/layout/dialogs/login_mojang.fxml b/src/main/resources/layout/dialogs/login/mojang.fxml similarity index 98% rename from src/main/resources/layout/dialogs/login_mojang.fxml rename to src/main/resources/layout/dialogs/login/mojang.fxml index 0c038eb11..e0201ea25 100644 --- a/src/main/resources/layout/dialogs/login_mojang.fxml +++ b/src/main/resources/layout/dialogs/login/mojang.fxml @@ -7,7 +7,7 @@ - + diff --git a/src/main/resources/layout/dialogs/login_offline.fxml b/src/main/resources/layout/dialogs/login/offline.fxml similarity index 97% rename from src/main/resources/layout/dialogs/login_offline.fxml rename to src/main/resources/layout/dialogs/login/offline.fxml index f26347eb6..efdceb6d0 100644 --- a/src/main/resources/layout/dialogs/login_offline.fxml +++ b/src/main/resources/layout/dialogs/login/offline.fxml @@ -6,7 +6,7 @@ - +